@wirecat/cli-core 0.516.0 → 0.518.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
@@ -7,6 +7,37 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.518.0 — 01.11.2026
11
+
12
+ ### Added
13
+
14
+ - **A memory names its project (store version 43)**: `memories.add({ project })`, `memories.list({ project })` and
15
+ `memories.update(ref, { project })`, which moves a memory to another project or to none with `null`. A memory and a
16
+ fact carry `project` and `projectName` (`project` and `project_name` in the `memory` and `fact` entities), so a reader
17
+ needs no second lookup for the name.
18
+ - **`evidenceFindings.sources(id)`** reads each reference a findings batch cites back once, in the shape of
19
+ `memories.sources`: `kind`, `title`, `sender`, `at`, an excerpt of at most 280 characters, `tool` and `missing`.
20
+
21
+ ### Fixed
22
+
23
+ - **A findings batch closed with nothing accepted reads as `rejected`** in `proposedActions.get` and `list`, and
24
+ `list({ status: "rejected" })` finds it. The store still writes it `approved`, so older builds read it as before.
25
+
26
+ ## 0.517.0 — 01.11.2026
27
+
28
+ ### Added
29
+
30
+ - **`withSyncCommands(found, sync)`** gives each linked chat of a stored project gathering its `syncCommand`: the
31
+ line the text names to sync a chat held in part, or null. tg's and max's `projects evidence --json` carry it as
32
+ `linked_chats[].sync_command`, so an agent reading JSON gets the command the text shows.
33
+
34
+ ### Fixed
35
+
36
+ - **A chat that reaches its first message is no longer "held from <date> only"** in `missing`, `next` and the
37
+ `projects evidence` text: it is "held from its first message, behind since <date>", or "with gaps".
38
+ - **`messages sync` progress says how far back it got as a date** ("back to 2026-09-27"), not as a message key, which
39
+ for MAX was a number of milliseconds.
40
+
10
41
  ## 0.516.0 — 01.11.2026
11
42
 
12
43
  ### Fixed
@@ -109,7 +109,7 @@ 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
+ export { type ProjectTextHints, projectAnswerParts, projectCutoffTime, projectEvidenceText, projectWindowOf, withSyncCommands, } from "./project-evidence.js";
113
113
  export { fieldsOf, projectFields } from "./result-fields.js";
114
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";
115
115
  export { type PermissionAnswer, readSetupAnswers, type SetupAnswers, setupPermissionCeiling } from "./setup-answers.js";
package/dist/cli/index.js CHANGED
@@ -99,7 +99,7 @@ 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
+ export { projectAnswerParts, projectCutoffTime, projectEvidenceText, projectWindowOf, withSyncCommands, } from "./project-evidence.js";
103
103
  export { fieldsOf, projectFields } from "./result-fields.js";
104
104
  export { count, first, flag, fromFile, NONE_NAMED, namedProfile, plain, settingsFor, } from "./settings.js";
105
105
  export { readSetupAnswers, setupPermissionCeiling } from "./setup-answers.js";
@@ -7,7 +7,7 @@ import { gatherStoredProject } from "../../services/stored-project-gathering.js"
7
7
  import { FETCH } from "../effects.js";
8
8
  import { itemOf, renderAnswer, snakeKeys } from "../envelope.js";
9
9
  import { positiveCount } from "../paging.js";
10
- import { projectAnswerParts, projectCutoffTime, projectEvidenceText, projectWindowOf } from "../project-evidence.js";
10
+ import { projectAnswerParts, projectCutoffTime, projectEvidenceText, projectWindowOf, withSyncCommands, } from "../project-evidence.js";
11
11
  import { lineOf } from "../steps.js";
12
12
  import { messengerContext } from "./context.js";
13
13
  const collect = (value, previous) => [...previous, value];
@@ -120,12 +120,13 @@ export const projectsCommand = (messenger) => {
120
120
  : undefined;
121
121
  const found = await context.withStore((store, account) => gather(store, account, fetcher));
122
122
  const parts = projectAnswerParts(options.sinceTime, found);
123
- renderAnswer(this, context, itemOf(snakeKeys(found), parts), () => projectEvidenceText(found, {
123
+ const hints = {
124
124
  sync: (one, since) => one.provider === messenger.provider
125
125
  ? lineOf(this, `projects evidence ${quote(project)} --since-time ${since} --sync-first`)
126
126
  : null,
127
127
  showDocument: "the notes app that imported it",
128
- }));
128
+ };
129
+ renderAnswer(this, context, itemOf(snakeKeys(withSyncCommands(found, hints.sync)), parts), () => projectEvidenceText(found, hints));
129
130
  }));
130
131
  return projects;
131
132
  };
@@ -18,6 +18,11 @@ export type HitItem = InferOutput<(typeof ENTITIES)["hit"]>;
18
18
  /** The page a `--page` asks for, as the offset the search services take. */
19
19
  export declare const offsetOf: (page: number | undefined, limit: number) => number | undefined;
20
20
  export declare const messageHitItem: (hit: FoundMessage, query?: string) => MessageHitItem;
21
+ /**
22
+ * What the store holds of a chat that is not complete, in words. A chat that reaches its first message is only
23
+ * behind or has gaps: saying "held from <date> only" would claim older history is missing.
24
+ */
25
+ export declare const heldPhrase: ({ completeness, held }: Pick<ChatCoverage, "completeness" | "held">) => string;
21
26
  /**
22
27
  * What the archive lacked for this answer, and the step that would fill it. From a search's coverage, or from the
23
28
  * chats an answer is about (`since` is the start of the window it was asked for).
@@ -96,6 +96,19 @@ const metaOf = (accounts, skipped = [], heldInPart = false) => ({
96
96
  : {}),
97
97
  });
98
98
  const day = (at) => at?.slice(0, 10) ?? "no date";
99
+ /**
100
+ * What the store holds of a chat that is not complete, in words. A chat that reaches its first message is only
101
+ * behind or has gaps: saying "held from <date> only" would claim older history is missing.
102
+ */
103
+ export const heldPhrase = ({ completeness, held }) => {
104
+ if (completeness.state === "unknown" || !held.oldestAt)
105
+ return "never fetched";
106
+ if (!completeness.reachesStart)
107
+ return `held from ${day(held.oldestAt)} only`;
108
+ if (completeness.gaps)
109
+ return "held from its first message, with gaps";
110
+ return `held from its first message, behind since ${day(held.newestAt)}`;
111
+ };
99
112
  /**
100
113
  * The steps for chats the store holds only in part. Bounded first, when the answer's window is known, then the whole
101
114
  * chat; both name the first chat only, because a download is the owner's call. Counts and dates only, never text.
@@ -105,7 +118,7 @@ const chatGaps = (rows, since) => {
105
118
  const [first] = open;
106
119
  if (!first)
107
120
  return { next: [], missing: [] };
108
- const held = first.completeness.state === "unknown" ? "never fetched" : `held from ${day(first.held.oldestAt)} only`;
121
+ const held = heldPhrase(first);
109
122
  const steps = (args, why) => ({
110
123
  ...nextStep("messages.sync", { args: { chat: first.chatId, ...args } }),
111
124
  why,
@@ -41,6 +41,102 @@ export declare const projectAnswerParts: (sinceTime: string, found: StoredProjec
41
41
  complete: boolean;
42
42
  };
43
43
  };
44
+ /**
45
+ * The gathering with each linked chat's `syncCommand`: the line that syncs a chat held in part back to the window
46
+ * start, or null when it needs none or no command here can. JSON readers get what the text says per chat.
47
+ */
48
+ export declare const withSyncCommands: (found: StoredProjectGathering, sync: ProjectTextHints["sync"]) => {
49
+ schemaVersion: 1;
50
+ runKey: string;
51
+ fingerprint: string;
52
+ resumed: boolean;
53
+ project: {
54
+ name: string;
55
+ key: string | null;
56
+ background: string | null;
57
+ aliases: string[];
58
+ };
59
+ window: {
60
+ after: number;
61
+ before: number;
62
+ asOf: number;
63
+ lookbackDays: number | null;
64
+ basis: "explicit" | "lookback";
65
+ timeBasis: "per-item-time-kind";
66
+ };
67
+ scope: {
68
+ origin: "all-connected" | "explicit";
69
+ sources: import("../context/evidence.js").ContextSource[];
70
+ };
71
+ lanes: import("../services/project-gathering-request.js").ProjectLane[];
72
+ fetch: import("../services/project-gathering-request.js").ProjectChatFetched[];
73
+ index: import("../services/project-gathering.js").ProjectIndexCatchUp;
74
+ units: {
75
+ lane: string;
76
+ source: number;
77
+ after: number;
78
+ before: number;
79
+ state: import("../services/project-gathering-request.js").ProjectUnitState;
80
+ pages: number;
81
+ issue: string | null;
82
+ }[];
83
+ items: import("../services/project-gathering.js").ProjectEvidenceItem[];
84
+ people: import("../services/project-gathering.js").ProjectEvidencePerson[];
85
+ peopleOmitted: number;
86
+ oversized: {
87
+ ref: string;
88
+ lanes: string[];
89
+ source: number;
90
+ bytes: number;
91
+ }[];
92
+ matchedAgain: {
93
+ ref: string;
94
+ lanes: string[];
95
+ }[];
96
+ usage: {
97
+ reads: number;
98
+ readBytes: number;
99
+ aroundBytes: number;
100
+ items: number;
101
+ elapsedMs: number;
102
+ };
103
+ totals: {
104
+ reads: number;
105
+ readBytes: number;
106
+ items: number;
107
+ invocations: number;
108
+ };
109
+ stopped: import("../services/project-gathering.js").ProjectGatheringStop;
110
+ complete: boolean;
111
+ completeness: "complete" | "partial" | "unknown";
112
+ checkpoint: string | null;
113
+ projectRef: string | null;
114
+ unreadSources: {
115
+ source: string;
116
+ kind: string;
117
+ reason?: "out-of-scope" | "linked-chat-limit" | "member-limit" | "no-identity" | "unknown-provider";
118
+ }[];
119
+ mentions: import("../services/project-gathering-mentions.js").EvidenceMentions;
120
+ linkedChats: {
121
+ provider: string;
122
+ account: string;
123
+ chat: string;
124
+ ref: string;
125
+ source: number | null;
126
+ lane: string | null;
127
+ state: "in-scope" | "out-of-scope" | "pending" | "unavailable";
128
+ title: string | null;
129
+ completeness: import("../store/exports.js").ChatCompleteness | null;
130
+ held: {
131
+ messages: number;
132
+ oldestAt: string | null;
133
+ newestAt: string | null;
134
+ } | null;
135
+ items?: number | undefined;
136
+ capped?: boolean | undefined;
137
+ syncCommand: string | null;
138
+ }[];
139
+ };
44
140
  export interface ProjectTextHints {
45
141
  /** The command that syncs one linked chat back to `since` (`YYYY-MM-DD`), or null when no command here can. */
46
142
  sync: (chat: StoredProjectGathering["linkedChats"][number], since: string) => string | null;
@@ -1,7 +1,7 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import * as v from "valibot";
3
3
  import { dateWindow, momentOf } from "../services/moment.js";
4
- import { coverageGaps } from "./messenger/search-answers.js";
4
+ import { coverageGaps, heldPhrase } from "./messenger/search-answers.js";
5
5
  /** Days back (`90d`) or a start date (`2026-09-01`); either way the run freezes its end, so a resume keeps it. */
6
6
  export const projectWindowOf = (value) => {
7
7
  const days = /^([1-9]\d{0,2})d$/.exec(value)?.[1];
@@ -47,6 +47,17 @@ export const projectAnswerParts = (sinceTime, found) => {
47
47
  },
48
48
  };
49
49
  };
50
+ /**
51
+ * The gathering with each linked chat's `syncCommand`: the line that syncs a chat held in part back to the window
52
+ * start, or null when it needs none or no command here can. JSON readers get what the text says per chat.
53
+ */
54
+ export const withSyncCommands = (found, sync) => ({
55
+ ...found,
56
+ linkedChats: found.linkedChats.map((one) => ({
57
+ ...one,
58
+ syncCommand: one.completeness === null || one.completeness.state === "complete" ? null : sync(one, day(found.window.after)),
59
+ })),
60
+ });
50
61
  const LABELS = {
51
62
  "person-only": " [person only]",
52
63
  sender: " [sent by contact]",
@@ -120,13 +131,11 @@ export const projectEvidenceText = (found, hints) => {
120
131
  for (const one of found.fetch)
121
132
  lines.push(`${one.chat}: ${FETCH_STATES[one.state] ?? one.state} before reading` +
122
133
  `${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
134
  for (const one of found.linkedChats)
127
135
  if (one.completeness !== null && one.completeness.state !== "complete") {
128
136
  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" : ""}` +
137
+ const held = one.held === null ? "never fetched" : heldPhrase({ completeness: one.completeness, held: one.held });
138
+ lines.push(`${one.title ?? one.ref}: ${held}${one.state === "pending" ? ", not read this run" : ""}` +
130
139
  (sync ? ` — sync it: ${sync}` : ""));
131
140
  }
132
141
  const capped = found.linkedChats.filter((one) => one.capped);
@@ -6,6 +6,8 @@ export declare const fact: v.StrictObjectSchema<{
6
6
  readonly text: v.StringSchema<undefined>;
7
7
  readonly status: v.PicklistSchema<readonly ["proposed", "accepted", "superseded", "reversed", "rejected"], undefined>;
8
8
  readonly project: v.NullableSchema<v.StringSchema<undefined>, undefined>;
9
+ /** What the project is called. */
10
+ readonly project_name: v.OptionalSchema<v.NullableSchema<v.StringSchema<undefined>, undefined>, undefined>;
9
11
  readonly subject: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
10
12
  /** What the subject is called; `null` for the owner and for a row with no name. */
11
13
  readonly subject_name: v.OptionalSchema<v.NullableSchema<v.StringSchema<undefined>, undefined>, undefined>;
@@ -8,6 +8,8 @@ export const fact = v.strictObject({
8
8
  text,
9
9
  status: v.picklist(FACT_STATUSES),
10
10
  project: v.nullable(text),
11
+ /** What the project is called. */
12
+ project_name: v.optional(v.nullable(text)),
11
13
  subject: v.nullable(reference),
12
14
  /** What the subject is called; `null` for the owner and for a row with no name. */
13
15
  subject_name: v.optional(v.nullable(text)),
@@ -5369,6 +5369,7 @@ export declare const ENTITIES: {
5369
5369
  readonly text: v.StringSchema<undefined>;
5370
5370
  readonly status: v.PicklistSchema<readonly ["proposed", "accepted", "superseded", "reversed", "rejected"], undefined>;
5371
5371
  readonly project: v.NullableSchema<v.StringSchema<undefined>, undefined>;
5372
+ readonly project_name: v.OptionalSchema<v.NullableSchema<v.StringSchema<undefined>, undefined>, undefined>;
5372
5373
  readonly subject: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
5373
5374
  readonly subject_name: v.OptionalSchema<v.NullableSchema<v.StringSchema<undefined>, undefined>, undefined>;
5374
5375
  readonly subject_is_owner: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
@@ -5408,6 +5409,8 @@ export declare const ENTITIES: {
5408
5409
  readonly subject: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
5409
5410
  readonly subject_name: v.OptionalSchema<v.NullableSchema<v.StringSchema<undefined>, undefined>, undefined>;
5410
5411
  readonly subject_is_owner: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
5412
+ readonly project: v.OptionalSchema<v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>, undefined>;
5413
+ readonly project_name: v.OptionalSchema<v.NullableSchema<v.StringSchema<undefined>, undefined>, undefined>;
5411
5414
  readonly author: v.StrictObjectSchema<{
5412
5415
  readonly type: v.PicklistSchema<["person", "bot"], undefined>;
5413
5416
  readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
@@ -8,6 +8,9 @@ export declare const memory: v.StrictObjectSchema<{
8
8
  /** What the subject is called; `null` for the owner and for a row with no name. */
9
9
  readonly subject_name: v.OptionalSchema<v.NullableSchema<v.StringSchema<undefined>, undefined>, undefined>;
10
10
  readonly subject_is_owner: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
11
+ /** The project it belongs to, and what that project is called. */
12
+ readonly project: v.OptionalSchema<v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>, undefined>;
13
+ readonly project_name: v.OptionalSchema<v.NullableSchema<v.StringSchema<undefined>, undefined>, undefined>;
11
14
  readonly author: v.StrictObjectSchema<{
12
15
  readonly type: v.PicklistSchema<["person", "bot"], undefined>;
13
16
  readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
@@ -10,6 +10,9 @@ export const memory = v.strictObject({
10
10
  /** What the subject is called; `null` for the owner and for a row with no name. */
11
11
  subject_name: v.optional(v.nullable(text)),
12
12
  subject_is_owner: v.optional(v.boolean()),
13
+ /** The project it belongs to, and what that project is called. */
14
+ project: v.optional(v.nullable(reference)),
15
+ project_name: v.optional(v.nullable(text)),
13
16
  author,
14
17
  model: v.nullable(text),
15
18
  confidence: v.nullable(v.number()),
@@ -308,7 +308,7 @@ export const fetchInto = async ({ history, store, account, fetching, limit, page
308
308
  before = byTime
309
309
  ? new Date(boundary + (fresh > 0 && !(window && fetching.beforeInclusive) ? 1 : 0)).toISOString()
310
310
  : String(boundary);
311
- note(`${fetched} messages so far, back to ${held.from}`);
311
+ note(`${fetched} messages so far, back to ${fromAt.slice(0, 10)}`);
312
312
  const wait = fetching.jitter ? pauseMs * (1 + Math.random()) : pauseMs;
313
313
  await sleep(wait, undefined, { signal: stop }).catch(() => { });
314
314
  }
@@ -1,4 +1,5 @@
1
1
  import { type FindingOutcome, type FindingsInput, type FindingsReceipt } from "../meetings/index.js";
2
+ import type { EvidenceSource } from "./sqlite/evidence-sources.js";
2
3
  export declare const EVIDENCE_FINDINGS_KIND = "evidence_findings";
3
4
  export declare const validateEvidenceFindings: (input: FindingsInput) => FindingsInput;
4
5
  /** Who decided: the owner, or an agent the host let decide for the owner, with its reason. */
@@ -60,4 +61,6 @@ export interface EvidenceFindingsStore {
60
61
  accept: string[];
61
62
  by?: EvidenceFindingsDecider;
62
63
  }): Promise<EvidenceFindingsBatch>;
64
+ /** Each reference the batch cites, read back once; one gone from the store since is `missing`. */
65
+ sources(id: string): Promise<EvidenceSource[]>;
63
66
  }
@@ -3,6 +3,7 @@ import { contextEvidenceFingerprint } from "../../context/evidence.js";
3
3
  import { findingFingerprint, findingHash } from "../../meetings/index.js";
4
4
  import { EVIDENCE_FINDINGS_KIND, validateEvidenceFindings, } from "../evidence-findings.js";
5
5
  import { contextRecordOf } from "./context-evidence.js";
6
+ import { sourcesOfReferences } from "./evidence-sources.js";
6
7
  import { findingsAgent, findingsCoreOver } from "./findings-core.js";
7
8
  import { authorActor } from "./memories.js";
8
9
  import { proposalOperationsOver } from "./proposed-actions.js";
@@ -34,7 +35,7 @@ export const evidenceFindingsStoreOver = (context) => {
34
35
  };
35
36
  const view = (proposal) => {
36
37
  const input = proposal.payload;
37
- if (!input || !proposal.target || !["proposed", "approved"].includes(proposal.status))
38
+ if (!input || !proposal.target || !["proposed", "approved", "rejected"].includes(proposal.status))
38
39
  return invalid("invalid stored findings batch");
39
40
  return {
40
41
  id: proposal.id,
@@ -123,6 +124,7 @@ export const evidenceFindingsStoreOver = (context) => {
123
124
  });
124
125
  },
125
126
  get: async (id) => view(load(id)),
127
+ sources: async (id) => sourcesOfReferences(database, view(load(id)).input.findings.flatMap((finding) => finding.evidence.map((one) => one.reference))),
126
128
  list: async ({ project, limit = 20, offset = 0, status } = {}) => {
127
129
  if (status !== undefined && status !== "proposed" && status !== "reviewed")
128
130
  return invalid("finding status is proposed or reviewed");
@@ -21,3 +21,5 @@ export declare const evidenceSourcesOf: (database: CacheDatabase, from: {
21
21
  type: string;
22
22
  id: number;
23
23
  }) => EvidenceSource[];
24
+ /** Each distinct reference read back, in order; one the store no longer resolves keeps its reference, `missing`. */
25
+ export declare const sourcesOfReferences: (database: CacheDatabase, references: readonly string[]) => EvidenceSource[];
@@ -1,5 +1,5 @@
1
1
  import { linkKind } from "./link-kinds.js";
2
- import { referenceOfThing, stateOfThing, storedThing } from "./things.js";
2
+ import { canonicalThingReference, referenceOfThing, stateOfThing, storedThing, thingOf } from "./things.js";
3
3
  const EXCERPT_CHARS = 280;
4
4
  const excerptOf = (text) => {
5
5
  if (text == null)
@@ -32,16 +32,7 @@ const KIND = {
32
32
  meeting_transcript: "meeting",
33
33
  meeting_transcript_row: "meeting",
34
34
  };
35
- /**
36
- * Every `evidence` link from a memory or fact, in the order they were written, including one whose target was
37
- * deleted since: `evidenceOf` drops those, so a reader would never learn the record lost a source.
38
- */
39
- export const evidenceSourcesOf = (database, from) => database
40
- .prepare(`SELECT to_type, to_id FROM links WHERE from_type = ? AND from_id = ? AND kind = ${linkKind("evidence")} ` +
41
- "AND to_id IS NOT NULL ORDER BY id")
42
- .all(from.type, from.id)
43
- .map((link) => {
44
- const thing = storedThing(link.to_type, link.to_id);
35
+ const sourceOf = (database, thing) => {
45
36
  const state = stateOfThing(database, thing);
46
37
  const read = READS[thing.type];
47
38
  const row = read === undefined || state === "unavailable" ? undefined : database.prepare(read).get(thing.id);
@@ -56,4 +47,37 @@ export const evidenceSourcesOf = (database, from) => database
56
47
  tool: textOf(row?.tool),
57
48
  missing: gone,
58
49
  };
50
+ };
51
+ /**
52
+ * Every `evidence` link from a memory or fact, in the order they were written, including one whose target was
53
+ * deleted since: `evidenceOf` drops those, so a reader would never learn the record lost a source.
54
+ */
55
+ export const evidenceSourcesOf = (database, from) => database
56
+ .prepare(`SELECT to_type, to_id FROM links WHERE from_type = ? AND from_id = ? AND kind = ${linkKind("evidence")} ` +
57
+ "AND to_id IS NOT NULL ORDER BY id")
58
+ .all(from.type, from.id)
59
+ .map((link) => sourceOf(database, storedThing(link.to_type, link.to_id)));
60
+ const thingNamed = (database, reference) => {
61
+ try {
62
+ return thingOf(database, canonicalThingReference(reference));
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ };
68
+ /** Each distinct reference read back, in order; one the store no longer resolves keeps its reference, `missing`. */
69
+ export const sourcesOfReferences = (database, references) => [...new Set(references)].map((reference) => {
70
+ const thing = thingNamed(database, reference);
71
+ if (thing)
72
+ return { ...sourceOf(database, thing), ref: reference };
73
+ return {
74
+ ref: reference,
75
+ kind: reference.startsWith("msg:") ? "message" : reference.split(":")[0] || "unknown",
76
+ title: null,
77
+ sender: null,
78
+ at: null,
79
+ excerpt: null,
80
+ tool: null,
81
+ missing: true,
82
+ };
59
83
  });
@@ -12,6 +12,7 @@ export interface Fact {
12
12
  /** Proposed, then accepted or rejected; a correction supersedes it; only a decision is reversed. */
13
13
  status: (typeof FACT_STATUSES)[number];
14
14
  project: string | null;
15
+ projectName: string | null;
15
16
  subject: string | null;
16
17
  subjectName: string | null;
17
18
  subjectIsOwner: boolean;
@@ -3,7 +3,7 @@ import { FACT_TYPES, MEMORY_SCOPES } from "../../domain/knowledge.js";
3
3
  import { calendarMoment } from "../../domain/moment.js";
4
4
  import { evidenceSourcesOf } from "./evidence-sources.js";
5
5
  import { linkKind } from "./link-kinds.js";
6
- import { authorActor, authorOf, dropLinksOf, erase, evidenceOf, keepHighestId, linkEvidence, nextId, subjectOf, } from "./memories.js";
6
+ import { authorActor, authorOf, dropLinksOf, erase, evidenceOf, keepHighestId, linkEvidence, nextId, projectOf, subjectOf, } from "./memories.js";
7
7
  import { requiredThing } from "./notes.js";
8
8
  import { write } from "./transaction.js";
9
9
  const NAMED = "SELECT f.*, b.name AS author_name FROM facts f LEFT JOIN bots b ON f.author_type = 'bot' AND b.id = f.author_id";
@@ -21,7 +21,7 @@ export const factOperationsOver = (context) => {
21
21
  type: row.type,
22
22
  statement: String(row.statement),
23
23
  status: row.status,
24
- project: row.project_id == null ? null : `project:${row.project_id}`,
24
+ ...projectOf(database, row),
25
25
  ...subjectOf(database, row),
26
26
  occurredAt: row.occurred_at == null ? null : iso(row.occurred_at),
27
27
  supersedes: row.supersedes_id == null ? null : `fact:${row.supersedes_id}`,
@@ -56,6 +56,7 @@ export const MANIFEST = [
56
56
  { name: "20261030225520_version-40-note-trigrams", version: 40, minCompatible: 34 },
57
57
  { name: "20261031230945_version-41-task-fields", version: 41, minCompatible: 34 },
58
58
  { name: "20261101002523_version-42-task-revision", version: 42, minCompatible: 34 },
59
+ { name: "20261101011859_version-43-memory-project", version: 43, minCompatible: 34 },
59
60
  ];
60
61
  export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
61
62
  const migrations = [];
@@ -43,7 +43,7 @@ export const meetingFindingsStoreOver = (context) => {
43
43
  };
44
44
  const view = (proposal) => {
45
45
  const input = proposal.payload;
46
- if (!input || !proposal.account || !["proposed", "approved"].includes(proposal.status))
46
+ if (!input || !proposal.account || !["proposed", "approved", "rejected"].includes(proposal.status))
47
47
  return invalid("invalid stored findings batch");
48
48
  return {
49
49
  id: proposal.id,
@@ -19,6 +19,9 @@ export interface Memory {
19
19
  subjectName: string | null;
20
20
  /** The subject is the owner. */
21
21
  subjectIsOwner: boolean;
22
+ /** The project it belongs to, as `project:<id>`. */
23
+ project: string | null;
24
+ projectName: string | null;
22
25
  /** `name` is a bot's handle; `null` for a person. */
23
26
  author: {
24
27
  type: Actor["type"];
@@ -45,6 +48,7 @@ export interface MemoryInput {
45
48
  evidence: string[];
46
49
  author: Author;
47
50
  subject?: string;
51
+ project?: string;
48
52
  confidence?: number;
49
53
  status?: "proposed" | "confirmed";
50
54
  /** The memory this one replaces; it becomes `superseded`. */
@@ -55,11 +59,16 @@ export interface MemoriesStore {
55
59
  get(reference: string): Promise<Memory>;
56
60
  list(options?: {
57
61
  subject?: string;
62
+ project?: string;
58
63
  kind?: Memory["kind"];
59
64
  status?: Memory["status"];
60
65
  scope?: Memory["scope"];
61
66
  limit?: number;
62
67
  }): Promise<Memory[]>;
68
+ /** Moves it to another project, or to none with `null`. */
69
+ update(reference: string, change: {
70
+ project: string | null;
71
+ }): Promise<Memory>;
63
72
  /** The owner stands behind it; its evidence was checked now. */
64
73
  confirm(reference: string): Promise<Memory>;
65
74
  /** Its evidence no longer holds. */
@@ -100,6 +109,16 @@ export declare const evidenceOf: (context: StoreContext, from: {
100
109
  }) => string[];
101
110
  /** What a memory's or fact's subject is called, for a reader who should not have to look the reference up. */
102
111
  export declare const subjectOf: (database: StoreContext["database"], row: Row) => Pick<Memory, "subject" | "subjectName" | "subjectIsOwner">;
112
+ /** A memory's or fact's project, with its name, so a reader does not look the project up. */
113
+ export declare const projectOf: (database: StoreContext["database"], row: Row) => {
114
+ project: null;
115
+ projectName: null;
116
+ } | {
117
+ project: string;
118
+ projectName: string | null;
119
+ };
120
+ /** A `project:<id>` the store holds, as its row id. */
121
+ export declare const projectId: (database: StoreContext["database"], reference: string) => number;
103
122
  type Numbered = "memories" | "facts";
104
123
  /**
105
124
  * The id the next row takes: above every row there is and every one forgotten. SQLite hands out the highest id + 1,
@@ -64,6 +64,20 @@ export const subjectOf = (database, row) => {
64
64
  subjectIsOwner: Number(named?.owner) === 1,
65
65
  };
66
66
  };
67
+ /** A memory's or fact's project, with its name, so a reader does not look the project up. */
68
+ export const projectOf = (database, row) => {
69
+ if (row.project_id == null)
70
+ return { project: null, projectName: null };
71
+ const named = database.prepare(SUBJECT_NAMES.project).get(Number(row.project_id));
72
+ return { project: `project:${row.project_id}`, projectName: named?.name == null ? null : String(named.name) };
73
+ };
74
+ /** A `project:<id>` the store holds, as its row id. */
75
+ export const projectId = (database, reference) => {
76
+ const project = requiredThing(database, reference, "the project");
77
+ if (project.type !== "project")
78
+ throw new CliError("validation_error", `${reference} is not a project`);
79
+ return project.id;
80
+ };
67
81
  const highestKey = (table) => `ids.${table}.highest`;
68
82
  /**
69
83
  * The id the next row takes: above every row there is and every one forgotten. SQLite hands out the highest id + 1,
@@ -118,6 +132,7 @@ export const memoriesStoreOver = (context) => {
118
132
  kind: row.kind,
119
133
  body: String(row.body),
120
134
  ...subjectOf(database, row),
135
+ ...projectOf(database, row),
121
136
  author: authorOf(row),
122
137
  model: row.model == null ? null : String(row.model),
123
138
  confidence: row.confidence == null ? null : Number(row.confidence),
@@ -167,12 +182,14 @@ export const memoriesStoreOver = (context) => {
167
182
  const id = write(database, () => {
168
183
  const { actor, model } = authorActor(context, input.author);
169
184
  const subject = input.subject === undefined ? undefined : requiredThing(database, input.subject, "the subject");
185
+ const project = input.project === undefined ? null : projectId(database, input.project);
170
186
  const replaced = input.supersedes === undefined ? undefined : Number(rowOf(input.supersedes).id);
171
187
  const at = now();
172
188
  const made = Number(database
173
189
  .prepare("INSERT INTO memories (id, kind, body, subject_type, subject_id, author_type, author_id, model, confidence, status, " +
174
- "last_verified_at, supersedes_id, scope, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id")
175
- .get(nextId(database, "memories"), kind, body, subject?.type ?? null, subject?.id ?? null, actor.type, actor.id, model, input.confidence ?? null, status, status === "confirmed" ? at : null, replaced ?? null, scope, at, at)?.id);
190
+ "last_verified_at, supersedes_id, scope, project_id, created_at, updated_at) " +
191
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id")
192
+ .get(nextId(database, "memories"), kind, body, subject?.type ?? null, subject?.id ?? null, actor.type, actor.id, model, input.confidence ?? null, status, status === "confirmed" ? at : null, replaced ?? null, scope, project, at, at)?.id);
176
193
  linkEvidence(context, { type: "memory", id: made }, input.evidence, input.author === "owner" ? "owner" : "agent");
177
194
  if (replaced !== undefined)
178
195
  database.prepare("UPDATE memories SET status = 'superseded', updated_at = ? WHERE id = ?").run(at, replaced);
@@ -183,13 +200,20 @@ export const memoriesStoreOver = (context) => {
183
200
  get: async (reference) => memoryOf(rowOf(reference)),
184
201
  list: async (options = {}) => {
185
202
  const subject = options.subject === undefined ? undefined : requiredThing(database, options.subject);
203
+ const project = options.project === undefined ? null : projectId(database, options.project);
186
204
  return database
187
205
  .prepare(`${NAMED} WHERE m.deleted_at IS NULL AND (? IS NULL OR (m.subject_type = ? AND m.subject_id = ?)) ` +
188
- "AND (? IS NULL OR m.kind = ?) AND (? IS NULL OR m.status = ?) AND (? IS NULL OR m.scope = ?) " +
206
+ "AND (? IS NULL OR m.project_id = ?) AND (? IS NULL OR m.kind = ?) AND (? IS NULL OR m.status = ?) AND (? IS NULL OR m.scope = ?) " +
189
207
  "ORDER BY m.created_at DESC, m.id DESC LIMIT ?")
190
- .all(subject?.type ?? null, subject?.type ?? null, subject?.id ?? null, options.kind ?? null, options.kind ?? null, options.status ?? null, options.status ?? null, options.scope ?? null, options.scope ?? null, Math.min(Math.max(options.limit ?? 100, 1), 500))
208
+ .all(subject?.type ?? null, subject?.type ?? null, subject?.id ?? null, project, project, options.kind ?? null, options.kind ?? null, options.status ?? null, options.status ?? null, options.scope ?? null, options.scope ?? null, Math.min(Math.max(options.limit ?? 100, 1), 500))
191
209
  .map(memoryOf);
192
210
  },
211
+ update: async (reference, change) => write(database, () => {
212
+ const id = Number(rowOf(reference).id);
213
+ const project = change.project === null ? null : projectId(database, change.project);
214
+ database.prepare("UPDATE memories SET project_id = ?, updated_at = ? WHERE id = ?").run(project, now(), id);
215
+ return memoryOf(rowOf(`memory:${id}`));
216
+ }),
193
217
  confirm: async (reference) => setStatus(reference, "confirmed", true),
194
218
  markStale: async (reference) => setStatus(reference, "stale", false),
195
219
  remove: async (reference) => {
@@ -870,5 +870,12 @@ export const GENERATED = [
870
870
  "CREATE TRIGGER tasks_revision_tagging_ai AFTER INSERT ON taggings WHEN new.taggable_type = 'task' BEGIN\n UPDATE tasks SET revision = revision + 1 WHERE id = new.taggable_id;\nEND;",
871
871
  "CREATE TRIGGER tasks_revision_tagging_ad AFTER DELETE ON taggings WHEN old.taggable_type = 'task' BEGIN\n UPDATE tasks SET revision = revision + 1 WHERE id = old.taggable_id;\nEND;"
872
872
  ]
873
+ },
874
+ {
875
+ "name": "20261101011859_version-43-memory-project",
876
+ "statements": [
877
+ "ALTER TABLE `memories` ADD `project_id` integer REFERENCES projects(id);",
878
+ "CREATE INDEX `memories_by_project_id` ON `memories` (`project_id`);"
879
+ ]
873
880
  }
874
881
  ];
@@ -8,9 +8,15 @@ import { write } from "./transaction.js";
8
8
  export const PROPOSAL_STATUSES = ["proposed", "approved", "rejected", "executed", "failed"];
9
9
  export const PROPOSAL_VERDICTS = ["useful", "not_useful"];
10
10
  const iso = (value) => (value == null ? null : new Date(Number(value)).toISOString());
11
- const NAMED = "SELECT p.*, b.name AS proposer_name FROM proposed_actions p " +
12
- "LEFT JOIN bots b ON p.proposed_by_type = 'bot' AND b.id = p.proposed_by_id";
13
11
  const json = (value) => (value == null ? null : JSON.parse(String(value)));
12
+ /**
13
+ * A findings batch closed with nothing accepted is stored `approved`, as older builds read it; it reads as
14
+ * `rejected`, the decision the owner made.
15
+ */
16
+ const STATUS = `CASE WHEN p.status = 'approved' AND p.kind IN ('${MEETING_FINDINGS_KIND}', '${EVIDENCE_FINDINGS_KIND}') ` +
17
+ "AND json_array_length(p.result, '$.accepted') = 0 THEN 'rejected' ELSE p.status END";
18
+ const NAMED = `SELECT p.*, ${STATUS} AS shown_status, b.name AS proposer_name FROM proposed_actions p ` +
19
+ "LEFT JOIN bots b ON p.proposed_by_type = 'bot' AND b.id = p.proposed_by_id";
14
20
  export const proposalOperationsOver = (context) => {
15
21
  const { database, now } = context;
16
22
  const actionOf = (row) => {
@@ -26,7 +32,7 @@ export const proposalOperationsOver = (context) => {
26
32
  : (referenceOfThing(database, storedThing(row.target_type, row.target_id)) ?? null),
27
33
  payload: json(row.payload),
28
34
  reason: row.reason == null ? null : String(row.reason),
29
- status: row.status,
35
+ status: row.shown_status,
30
36
  proposedBy: {
31
37
  type: row.proposed_by_type,
32
38
  id: String(row.proposed_by_id),
@@ -106,7 +112,7 @@ export const proposalOperationsOver = (context) => {
106
112
  if (status !== undefined && !PROPOSAL_STATUSES.includes(status))
107
113
  throw new CliError("validation_error", `a status is one of ${PROPOSAL_STATUSES.join(", ")}`);
108
114
  return database
109
- .prepare(`${NAMED} WHERE (? IS NULL OR p.status = ?) ORDER BY p.created_at DESC, p.id DESC LIMIT ?`)
115
+ .prepare(`${NAMED} WHERE (? IS NULL OR ${STATUS} = ?) ORDER BY p.created_at DESC, p.id DESC LIMIT ?`)
110
116
  .all(status ?? null, status ?? null, Math.min(Math.max(limit, 1), 500))
111
117
  .map(actionOf);
112
118
  },
@@ -8226,6 +8226,22 @@ export declare const memories: import("drizzle-orm/sqlite-core").SQLiteTableWith
8226
8226
  identity: undefined;
8227
8227
  generated: undefined;
8228
8228
  }, {}>;
8229
+ projectId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
8230
+ name: string;
8231
+ tableName: "memories";
8232
+ dataType: "number int53";
8233
+ data: number;
8234
+ driverParam: number;
8235
+ notNull: false;
8236
+ hasDefault: false;
8237
+ isPrimaryKey: false;
8238
+ isAutoincrement: false;
8239
+ hasRuntimeDefault: false;
8240
+ enumValues: undefined;
8241
+ baseColumn: never;
8242
+ identity: undefined;
8243
+ generated: undefined;
8244
+ }, {}>;
8229
8245
  };
8230
8246
  dialect: 'sqlite';
8231
8247
  isAlias: false;
@@ -872,10 +872,12 @@ export const memories = sqliteTable("memories", {
872
872
  createdAt: integer("created_at").notNull(),
873
873
  updatedAt: integer("updated_at").notNull(),
874
874
  deletedAt: integer("deleted_at"),
875
+ projectId: integer("project_id").references(() => projects.id),
875
876
  }, (table) => [
876
877
  index("memories_by_subject_type_subject_id").on(table.subjectType, table.subjectId),
877
878
  index("memories_by_author_type_author_id").on(table.authorType, table.authorId),
878
879
  index("memories_by_supersedes_id").on(table.supersedesId),
880
+ index("memories_by_project_id").on(table.projectId),
879
881
  ]);
880
882
  export const memoryIndexPending = sqliteTable("memory_index_pending", {
881
883
  id: integer("id").notNull(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.516.0",
3
+ "version": "0.518.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",