@wirecat/cli-core 0.503.0 → 0.505.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 +36 -0
- package/dist/cli/app-doctor-command.d.ts +14 -1
- package/dist/cli/app-doctor-command.js +49 -7
- package/dist/cli/doctor-text.d.ts +2 -0
- package/dist/cli/doctor-text.js +2 -0
- package/dist/cli/index.d.ts +2 -1
- package/dist/cli/index.js +2 -1
- package/dist/cli/messenger/doctor-command.js +3 -2
- package/dist/cli/messenger/projects-command.d.ts +3 -0
- package/dist/cli/messenger/projects-command.js +134 -0
- package/dist/cli/project-evidence.d.ts +50 -0
- package/dist/cli/project-evidence.js +142 -0
- package/dist/cli/shared-command-exports.d.ts +1 -0
- package/dist/cli/shared-command-exports.js +2 -0
- package/dist/errors/reasons.js +1 -0
- package/dist/sends/permissions.js +1 -1
- package/dist/services/index.d.ts +3 -2
- package/dist/services/index.js +1 -0
- package/dist/services/project-gathering-request.d.ts +11 -6
- package/dist/services/project-gathering-request.js +1 -0
- package/dist/services/project-gathering.d.ts +29 -18
- package/dist/services/project-gathering.js +88 -26
- package/dist/services/stored-project-gathering.d.ts +50 -0
- package/dist/services/stored-project-gathering.js +207 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,42 @@ shape of this file.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.505.0 — 31.10.2026
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`appDoctorCommand` takes `--online`, and a messenger's and an app's `doctor` now say one thing.** Both use the
|
|
15
|
+
summary "the state this installation is in, without connecting unless --online" and the option text "also connect
|
|
16
|
+
once and read the account; sends nothing" (one constant, `doctor-text.ts`). Without the flag the answer is as before
|
|
17
|
+
and nothing is called. With it, `online` joins the answer: `{ ok, duration_ms, version: { current, latest, newer } }`,
|
|
18
|
+
plus the fields of the new `AppDoctorOptions.online(env)` hook, where a CLI checks its own service (a login, a folder
|
|
19
|
+
read); a throw there is `ok: false` with `error_code` and `error`, never a failed command. `version` comes from npm
|
|
20
|
+
(the `latestVersion` that `upgrade` uses) when the CLI passes the new `AppDoctorOptions.packageName`; without it that
|
|
21
|
+
check is left out. The command's effects are now `REMOTE_READ`, as the messenger's.
|
|
22
|
+
|
|
23
|
+
## 0.504.0 — 31.10.2026
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- **`projects evidence --sync-first` in messenger CLIs: `projectsCommand(messenger)`.** A CLI
|
|
28
|
+
that mounts it reads a project's evidence from its profile's stored messages; `--sync-first` (off by default, not on
|
|
29
|
+
MCP) first syncs the project's linked chats the store does not hold back to `--since-time`, within `--sync-time`
|
|
30
|
+
(3 minutes), one chat at a time. A chat the time cut stays partial with its `missing` and `next`. The command is
|
|
31
|
+
declared a fetch (`remote: read`, `local: write`) because of `--sync-first`; cli-base fixes effects per command.
|
|
32
|
+
It prints what people wrote, so the `messages` permission guards it.
|
|
33
|
+
- **`gatherStoredProject`** (services) and **`projectAnswerParts`, `projectEvidenceText`, `projectWindowOf`,
|
|
34
|
+
`projectCutoffTime`** (cli): memo's `projects evidence` over a store, moved here so memo and the messenger CLIs
|
|
35
|
+
share it. The host resolves the sources and names its own sync command in the text.
|
|
36
|
+
|
|
37
|
+
### Changed — may break callers
|
|
38
|
+
|
|
39
|
+
- **`gatherProjectEvidence`'s fetch port is per linked chat.** `ProjectSourceFetcher` (one fetch per source, no
|
|
40
|
+
caller) is replaced by `ProjectChatFetcher`: `fetch({ account, chatId, after, before, until, signal, onProgress })` →
|
|
41
|
+
`{ state: "complete" | "partial" | "failed", reason? }`. `fetch: "before-read"` now needs the `coverage` port too,
|
|
42
|
+
takes `budgets.maxFetchMs`, and fetches only linked chats the store does not hold for the window. The result's
|
|
43
|
+
`fetch` lists those chats (`ProjectChatFetched`) instead of one row per source; `ProjectFetchState` is gone. The
|
|
44
|
+
read budgets, `maxElapsedMs` included, start after the fetch.
|
|
45
|
+
|
|
10
46
|
## 0.503.0 — 31.10.2026
|
|
11
47
|
|
|
12
48
|
### Added
|
|
@@ -9,6 +9,15 @@ export interface AppDoctorOptions {
|
|
|
9
9
|
oauthClient?: (env: NodeJS.ProcessEnv) => {
|
|
10
10
|
client: "wirecat" | "own";
|
|
11
11
|
};
|
|
12
|
+
/** The npm package, so `--online` can say whether a newer version is published; without it that check is left out. */
|
|
13
|
+
packageName?: string;
|
|
14
|
+
/**
|
|
15
|
+
* What `--online` checks of the service: a login, a folder read. Its fields join the `online` answer, and `ok: false`
|
|
16
|
+
* (or a throw, reported as `error_code` and `error`) makes the whole check fail without failing the command.
|
|
17
|
+
*/
|
|
18
|
+
online?: (env: NodeJS.ProcessEnv) => Promise<{
|
|
19
|
+
ok: boolean;
|
|
20
|
+
} & Record<string, unknown>>;
|
|
12
21
|
}
|
|
13
22
|
/**
|
|
14
23
|
* Run when everything else is broken, so a problem in the config file or the OAuth setting is a field, never a throw.
|
|
@@ -103,5 +112,9 @@ export declare const appDoctorReport: (app: AppIdentity, configuration: Configur
|
|
|
103
112
|
};
|
|
104
113
|
fixes: string[] | never[];
|
|
105
114
|
};
|
|
106
|
-
/**
|
|
115
|
+
/**
|
|
116
|
+
* `doctor` for a CLI that is not a messenger: gcal, drive, zoom. A messenger's is `doctorCommand(messenger)`, and both
|
|
117
|
+
* say the same thing: the state from disk, and only with `--online` a call out. Here that call is `options.online`
|
|
118
|
+
* and, with `options.packageName`, whether npm has a newer version.
|
|
119
|
+
*/
|
|
107
120
|
export declare const appDoctorCommand: (app: AppIdentity, configuration: Configuration, options?: AppDoctorOptions) => Command;
|
|
@@ -2,15 +2,48 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { dirname, resolve } from "node:path";
|
|
3
3
|
import { CliError, configFilePath, resolvePaths } from "@wirecat/cli-base";
|
|
4
4
|
import { annotate } from "@wirecat/cli-base/commands";
|
|
5
|
+
import { isNewer, latestVersion } from "@wirecat/cli-base/update";
|
|
5
6
|
import { Command } from "commander";
|
|
6
7
|
import { envName, readOnlyLockOf } from "../ports/app.js";
|
|
7
8
|
import { storePath } from "../store/path.js";
|
|
8
9
|
import { environmentOf, outputFor } from "./context.js";
|
|
9
|
-
import {
|
|
10
|
+
import { DOCTOR_ONLINE_OPTION, DOCTOR_SUMMARY } from "./doctor-text.js";
|
|
11
|
+
import { REMOTE_READ } from "./effects.js";
|
|
10
12
|
import { itemOf, renderAnswer } from "./envelope.js";
|
|
11
13
|
import { privateFiles } from "./messenger/private-files.js";
|
|
12
14
|
import { unknownPermissionIssues } from "./permission-keys.js";
|
|
13
15
|
import { profileFree } from "./profile.js";
|
|
16
|
+
const onlineCheck = async (app, env, fetcher, options) => {
|
|
17
|
+
const started = Date.now();
|
|
18
|
+
let service = { ok: true };
|
|
19
|
+
if (options.online)
|
|
20
|
+
try {
|
|
21
|
+
service = await options.online(env);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
service = {
|
|
25
|
+
ok: false,
|
|
26
|
+
error_code: error instanceof CliError ? error.code : "generic_failure",
|
|
27
|
+
error: error instanceof Error ? error.message : String(error),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const latest = options.packageName
|
|
31
|
+
? await latestVersion(options.packageName, fetcher, { timeoutMs: 3000 })
|
|
32
|
+
: undefined;
|
|
33
|
+
return {
|
|
34
|
+
...service,
|
|
35
|
+
duration_ms: Date.now() - started,
|
|
36
|
+
...(options.packageName
|
|
37
|
+
? {
|
|
38
|
+
version: {
|
|
39
|
+
current: app.version,
|
|
40
|
+
latest: latest ?? null,
|
|
41
|
+
newer: latest !== undefined && isNewer(latest, app.version),
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
: {}),
|
|
45
|
+
};
|
|
46
|
+
};
|
|
14
47
|
const failure = (error) => {
|
|
15
48
|
if (error instanceof CliError)
|
|
16
49
|
return error.message;
|
|
@@ -85,17 +118,26 @@ export const appDoctorReport = (app, configuration, env, command, options = {})
|
|
|
85
118
|
};
|
|
86
119
|
}
|
|
87
120
|
};
|
|
88
|
-
/**
|
|
121
|
+
/**
|
|
122
|
+
* `doctor` for a CLI that is not a messenger: gcal, drive, zoom. A messenger's is `doctorCommand(messenger)`, and both
|
|
123
|
+
* say the same thing: the state from disk, and only with `--online` a call out. Here that call is `options.online`
|
|
124
|
+
* and, with `options.packageName`, whether npm has a newer version.
|
|
125
|
+
*/
|
|
89
126
|
export const appDoctorCommand = (app, configuration, options = {}) => profileFree(annotate(new Command("doctor"), {
|
|
90
|
-
effects:
|
|
127
|
+
effects: REMOTE_READ,
|
|
91
128
|
examples: [`${app.command} doctor`, `${app.command} doctor --json`],
|
|
92
129
|
}))
|
|
93
|
-
.description(
|
|
94
|
-
.
|
|
95
|
-
|
|
130
|
+
.description(DOCTOR_SUMMARY)
|
|
131
|
+
.option("--online", DOCTOR_ONLINE_OPTION)
|
|
132
|
+
.action(async function () {
|
|
133
|
+
const environment = environmentOf(this);
|
|
134
|
+
const env = environment.env ?? process.env;
|
|
96
135
|
const { report, fixes } = appDoctorReport(app, configuration, env, this, options);
|
|
136
|
+
const online = this.opts().online
|
|
137
|
+
? await onlineCheck(app, env, environment.fetch ?? fetch, options)
|
|
138
|
+
: undefined;
|
|
97
139
|
const output = outputFor(this);
|
|
98
|
-
renderAnswer(this, output, itemOf(report));
|
|
140
|
+
renderAnswer(this, output, itemOf(online ? { ...report, online } : report));
|
|
99
141
|
for (const fix of fixes)
|
|
100
142
|
output.renderer.note(`to make it private: ${fix}`);
|
|
101
143
|
});
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -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";
|
|
@@ -10,6 +10,7 @@ import { FloodMemory, floodPathFor } from "../../sends/flood.js";
|
|
|
10
10
|
import { SendJournal, sendsPathFor } from "../../sends/journal.js";
|
|
11
11
|
import { storePath } from "../../store/path.js";
|
|
12
12
|
import { baseContext, environmentOf, outputFor } from "../context.js";
|
|
13
|
+
import { DOCTOR_ONLINE_OPTION, DOCTOR_SUMMARY } from "../doctor-text.js";
|
|
13
14
|
import { destroysNothing, LOCAL_WRITE, NO_EFFECTS, REMOTE_READ } from "../effects.js";
|
|
14
15
|
import { itemOf, renderAnswer, snakeKeys } from "../envelope.js";
|
|
15
16
|
import { unknownPermissionIssues } from "../permission-keys.js";
|
|
@@ -28,8 +29,8 @@ import { storeSummary } from "./store-maintenance-command.js";
|
|
|
28
29
|
*/
|
|
29
30
|
export const doctorCommand = (messenger) => {
|
|
30
31
|
const command = annotate(new Command("doctor"), { effects: REMOTE_READ })
|
|
31
|
-
.description(
|
|
32
|
-
.option("--online",
|
|
32
|
+
.description(DOCTOR_SUMMARY)
|
|
33
|
+
.option("--online", DOCTOR_ONLINE_OPTION)
|
|
33
34
|
.action(async function () {
|
|
34
35
|
const { online } = this.opts();
|
|
35
36
|
const { report, renderer, fixes = [] } = await diagnose(this, messenger);
|
|
@@ -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);
|
package/dist/errors/reasons.js
CHANGED
|
@@ -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}`);
|
package/dist/services/index.d.ts
CHANGED
|
@@ -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,
|
|
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 {
|
|
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";
|
package/dist/services/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
|
6
|
-
/**
|
|
7
|
-
export interface
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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?:
|
|
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 &&
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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() -
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.505.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",
|