@wirecat/cli-core 0.416.0 → 0.418.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,22 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.418.0 — 25.10.2026
11
+
12
+ ### Added
13
+
14
+ - **Project evidence says who wrote it.** `gatherProjectEvidence` returns `people`: who sent the run's message and mail items, person by person from the stored contacts (identities a person holds across messengers and mail count as one), with an item count split by context, the owner left out, at most 20 entries and `peopleOmitted` for the rest. It never matches names in the text. The context reader's message and mail items carry `sender` (identity, linked person, `owner`) to make that possible; project and meeting preparation items do not repeat it. See `docs/dev/PROJECT-GATHERING.md`.
15
+
16
+ ### Fixed
17
+
18
+ - **YAML front matter at the top of a Markdown file is no longer read as a rule and a heading.** `markdownBlocks` leaves it out, so a mirrored Doc does not show it, and a Doc edit written back to the file keeps it byte for byte. A `---` line further down is still a rule.
19
+
20
+ ## 0.417.0 — 25.10.2026
21
+
22
+ ### Fixed
23
+
24
+ - **`--discover` finds messages for a question that ends with "?".** When the question planner left a question as written ("Why did we drop Manchester?", «Почему поменяли цену?»), its "?" parsed as a one-character wildcard and discovery fell back to strict search, which found nothing. In discovery, a "?" after a word and before a space or the end is now punctuation. Strict search is unchanged.
25
+
10
26
  ## 0.416.0 — 25.10.2026
11
27
 
12
28
  ### Fixed
@@ -39,6 +39,7 @@ export declare const mapPreparationCommunications: ({ items, ...section }: Meeti
39
39
  text: string;
40
40
  timeKind: "sent" | "source-modified" | "stored-update" | "meeting-start";
41
41
  revision: number | null;
42
+ sender?: import("../context/evidence.js").ContextEvidenceSender | null;
42
43
  selectionId: string;
43
44
  textTruncated: boolean;
44
45
  at: string;
@@ -62,6 +62,15 @@ export const validateContextEvidencePage = (page, request) => {
62
62
  ? !["source-modified", "stored-update"].includes(item.timeKind)
63
63
  : item.timeKind !== (source.kind === "meeting" ? "meeting-start" : "sent"))
64
64
  fail();
65
+ const sender = item.sender;
66
+ if (sender != null &&
67
+ (typeof sender !== "object" ||
68
+ typeof sender.provider !== "string" ||
69
+ typeof sender.id !== "string" ||
70
+ typeof sender.owner !== "boolean" ||
71
+ [sender.name, sender.person, sender.personName].some((v) => v !== null && typeof v !== "string") ||
72
+ (sender.person !== null && !/^person:[1-9]\d*$/.test(sender.person))))
73
+ fail();
65
74
  const text = [item.text, item.title, item.url, item.provider, item.account, item.timeKind];
66
75
  if (text.some((value) => value !== null && typeof value !== "string"))
67
76
  fail();
@@ -36,6 +36,19 @@ export interface ContextEvidenceItem {
36
36
  timeKind: "sent" | "source-modified" | "stored-update" | "meeting-start";
37
37
  updatedAt: number;
38
38
  revision: number | null;
39
+ /** Who wrote a message or email, as stored; absent for documents and meeting cues. */
40
+ sender?: ContextEvidenceSender | null;
41
+ }
42
+ export interface ContextEvidenceSender {
43
+ provider: string;
44
+ /** The identity's id in its provider; an email address for mail. */
45
+ id: string;
46
+ name: string | null;
47
+ /** `person:<id>` when the store links the identity to a person. */
48
+ person: string | null;
49
+ personName: string | null;
50
+ /** The identity is the owner's: linked to the owner person, or the account's own. */
51
+ owner: boolean;
39
52
  }
40
53
  export interface ContextEvidencePage {
41
54
  source: ContextSource;
@@ -8,8 +8,11 @@ const loadParser = () => (parser ??= Promise.all([
8
8
  ]).then(([{ fromMarkdown }, { gfmFromMarkdown }, { gfm }]) => (markdown) => fromMarkdown(markdown, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })));
9
9
  /** Markdown (CommonMark + GFM tables, task lists and strikethrough) as the blocks a provider can write natively. */
10
10
  export const markdownBlocks = async (markdown) => (await placedMarkdownBlocks(markdown)).map((placed) => placed.block);
11
+ // YAML front matter is the file's metadata, not its text: CommonMark alone reads it as a rule and a heading.
12
+ const FRONT_MATTER = /^---[ \t]*\r?\n(?:[^\n]*\n)*?---[ \t]*(?=\r?\n|$)/;
11
13
  export const placedMarkdownBlocks = async (markdown) => {
12
- const tree = (await loadParser())(markdown);
14
+ // Blanked, not cut, so every offset and column still points into the file as written.
15
+ const tree = (await loadParser())(markdown.replace(FRONT_MATTER, (matter) => matter.replace(/[^\n]/g, " ")));
13
16
  const definitions = new Map();
14
17
  const collect = (node) => {
15
18
  if (node.type === "definition")
@@ -5,6 +5,6 @@ export interface ScopedContextEvidenceReader extends ContextEvidenceReader {
5
5
  usage(): ContextReadUsage;
6
6
  }
7
7
  export declare const createContextEvidenceReader: (port: ContextEvidenceReader, allowed: readonly ContextSource[], limits?: ContextReadLimits) => ScopedContextEvidenceReader;
8
- export type { ContextEvidenceItem, ContextEvidencePage, ContextEvidenceReader, ContextEvidenceRequest, ContextIndex, ContextRecordReader, ContextSource, } from "../context/evidence.js";
8
+ export type { ContextEvidenceItem, ContextEvidencePage, ContextEvidenceReader, ContextEvidenceRequest, ContextEvidenceSender, ContextIndex, ContextRecordReader, ContextSource, } from "../context/evidence.js";
9
9
  export { contextEvidenceFingerprint } from "../context/evidence.js";
10
10
  export type { ContextReadLimits, ContextReadUsage } from "../context/read-budget.js";
@@ -1,5 +1,5 @@
1
1
  import { type BotCheckService } from "../botcheck/service.js";
2
- export type { ContextEvidenceItem, ContextEvidencePage, ContextEvidenceReader, ContextEvidenceRequest, ContextIndex, ContextReadLimits, ContextReadUsage, ContextRecordReader, ContextSource, ScopedContextEvidenceReader, } from "./context-evidence.js";
2
+ export type { ContextEvidenceItem, ContextEvidencePage, ContextEvidenceReader, ContextEvidenceRequest, ContextEvidenceSender, ContextIndex, ContextReadLimits, ContextReadUsage, ContextRecordReader, ContextSource, ScopedContextEvidenceReader, } from "./context-evidence.js";
3
3
  export { contextEvidenceFingerprint, createContextEvidenceReader } from "./context-evidence.js";
4
4
  export { excerpt } from "./excerpt.js";
5
5
  export type { MediaArchiveOptions, MediaArchivePorts } from "./media-archive.js";
@@ -8,7 +8,7 @@ export type { MediaArchiveEntry, MediaIdentity } from "./media-archive-manifest.
8
8
  export type { MoveMediaArchiveOptions } from "./media-archive-move.js";
9
9
  export { moveMediaArchive } from "./media-archive-move.js";
10
10
  export { type DownloadedModel, downloadImageModel, downloadTextModel, type ModelDownloadOptions, } from "./model-downloads.js";
11
- export type { ProjectEvidenceContext, ProjectEvidenceItem, ProjectGathering, ProjectGatheringPorts, ProjectGatheringStop, ProjectIndexCatchUp, ProjectSourceFetcher, } from "./project-gathering.js";
11
+ export type { ProjectEvidenceContext, ProjectEvidenceItem, ProjectEvidencePerson, ProjectGathering, ProjectGatheringPorts, ProjectGatheringStop, ProjectIndexCatchUp, ProjectSourceFetcher, } from "./project-gathering.js";
12
12
  export { gatherProjectEvidence } from "./project-gathering.js";
13
13
  export type { ProjectNeighbour, ProjectNeighbourReader } from "./project-gathering-around.js";
14
14
  export { documentPassage, storedMessageNeighbours, storedNeighbours } from "./project-gathering-around.js";
@@ -55,7 +55,8 @@ export const readPreparationCommunications = async (plan, signal) => {
55
55
  const text = excerpt(item.text, plan.input.query, 300, { literal: true });
56
56
  // excerpt() puts every text on one line, so only a shorter result means words were cut.
57
57
  const whole = item.text.replace(/\s+/g, " ").trim();
58
- items.push({ ...item, text, selectionId: read.id, textTruncated: text !== whole });
58
+ const { sender: _, ...rest } = item;
59
+ items.push({ ...rest, text, selectionId: read.id, textTruncated: text !== whole });
59
60
  }
60
61
  }
61
62
  catch (error) {
@@ -1,4 +1,4 @@
1
1
  import type { MessengerTraits } from "../ports/traits.js";
2
2
  import type { AccountKey, MessageStore } from "../store/store.js";
3
3
  import type { SearchFound, SearchQuery } from "./messages.js";
4
- export declare const searchDiscovery: (store: MessageStore, account: AccountKey, request: SearchQuery, messenger?: Partial<Pick<MessengerTraits, "savedChatId" | "app" | "history" | "provider">>) => Promise<SearchFound>;
4
+ export declare const searchDiscovery: (store: MessageStore, account: AccountKey, asked: SearchQuery, messenger?: Partial<Pick<MessengerTraits, "savedChatId" | "app" | "history" | "provider">>) => Promise<SearchFound>;
@@ -5,7 +5,11 @@ import { exhausted, QUERY_LIMITS } from "../search/lucene/types.js";
5
5
  import { planQuestion, words } from "../search/question-plan.js";
6
6
  import { searchCombined } from "./messages-combined.js";
7
7
  import { prepareLucene, searchLucene } from "./messages-search.js";
8
- export const searchDiscovery = async (store, account, request, messenger = {}) => {
8
+ export const searchDiscovery = async (store, account, asked, messenger = {}) => {
9
+ // A question's own "?" would parse as a one-character wildcard and send it to strict search (BUG-395).
10
+ const request = asked.ast !== undefined || asked.exact || !asked.text
11
+ ? asked
12
+ : { ...asked, text: asked.text.replace(/(?<=\p{L})\?(?=\s|$)/gu, "") };
9
13
  const original = await prepareLucene(store, account, request, messenger);
10
14
  const text = request.text ?? "";
11
15
  const plan = planQuestion(text, request.ast !== undefined || !!request.exact || !!request.newest);
@@ -26,7 +26,7 @@ export interface ProjectIndexCatchUp {
26
26
  documents: number;
27
27
  code: string | null;
28
28
  }
29
- export interface ProjectEvidenceItem extends Omit<ContextEvidenceItem, "text"> {
29
+ export interface ProjectEvidenceItem extends Omit<ContextEvidenceItem, "text" | "sender"> {
30
30
  kind: ContextSource["kind"];
31
31
  source: number;
32
32
  text: string;
@@ -44,6 +44,18 @@ export interface ProjectEvidenceItem extends Omit<ContextEvidenceItem, "text"> {
44
44
  * naming only a person.
45
45
  */
46
46
  export type ProjectEvidenceContext = "project" | "linked-chat" | "sender" | "person-only";
47
+ /** Who wrote this run's message and mail items: one stored person, or one identity linked to none. */
48
+ export interface ProjectEvidencePerson {
49
+ person: string | null;
50
+ name: string;
51
+ identities: {
52
+ provider: string;
53
+ account: string;
54
+ id: string;
55
+ }[];
56
+ items: number;
57
+ contexts: Partial<Record<ProjectEvidenceContext, number>>;
58
+ }
47
59
  export type ProjectGatheringStop = "reads" | "read-bytes" | "elapsed" | "items" | null;
48
60
  export declare const gatherProjectEvidence: (ports: ProjectGatheringPorts, request: ProjectGatheringRequest) => Promise<{
49
61
  schemaVersion: 1;
@@ -97,6 +109,8 @@ export declare const gatherProjectEvidence: (ports: ProjectGatheringPorts, reque
97
109
  issue: string | null;
98
110
  }[];
99
111
  items: ProjectEvidenceItem[];
112
+ people: ProjectEvidencePerson[];
113
+ peopleOmitted: number;
100
114
  oversized: {
101
115
  ref: string;
102
116
  lanes: string[];
@@ -6,6 +6,7 @@ import { attachNeighbours, documentMatches, } from "./project-gathering-around.j
6
6
  import { decodeCheckpoint, encodeCheckpoint, projectGatheringRequest, projectUnits, seenKey, } from "./project-gathering-request.js";
7
7
  const terminal = new Set(["done", "unavailable", "failed"]);
8
8
  const PAGE = 20;
9
+ const PEOPLE = 20;
9
10
  const CONTEXTS = ["person-only", "sender", "linked-chat", "project"];
10
11
  const contextOf = (lane) => lane.kinds.includes("linked-chat")
11
12
  ? "linked-chat"
@@ -81,6 +82,7 @@ export const gatherProjectEvidence = async (ports, request) => {
81
82
  const usage = { reads: 0, readBytes: 0, aroundBytes: 0, items: 0 };
82
83
  const items = [];
83
84
  const byRef = new Map();
85
+ const senderOf = new Map();
84
86
  const matchedAgain = new Map();
85
87
  const oversized = new Map();
86
88
  let stopped = null;
@@ -117,8 +119,11 @@ export const gatherProjectEvidence = async (ports, request) => {
117
119
  seen.add(seenKey(item.ref));
118
120
  const short = excerpt(item.text, lane.query ?? "", 300, { literal: true });
119
121
  const fingerprint = contextEvidenceFingerprint(item);
122
+ const { sender, ...stored } = item;
123
+ if (sender)
124
+ senderOf.set(item.ref, sender);
120
125
  const entry = {
121
- ...item,
126
+ ...stored,
122
127
  kind,
123
128
  source,
124
129
  text: short,
@@ -258,6 +263,7 @@ export const gatherProjectEvidence = async (ports, request) => {
258
263
  usage.aroundBytes += bytes;
259
264
  },
260
265
  }, (ref) => byRef.has(ref) || seen.has(seenKey(ref)), signal);
266
+ const people = peopleOf(items, senderOf);
261
267
  const totals = {
262
268
  reads: (previous?.totals.reads ?? 0) + usage.reads,
263
269
  readBytes: (previous?.totals.readBytes ?? 0) + usage.readBytes,
@@ -321,6 +327,8 @@ export const gatherProjectEvidence = async (ports, request) => {
321
327
  issue: progress[index]?.code ?? null,
322
328
  })),
323
329
  items,
330
+ people: people.slice(0, PEOPLE),
331
+ peopleOmitted: Math.max(0, people.length - PEOPLE),
324
332
  oversized: [...oversized.values()],
325
333
  matchedAgain: [...matchedAgain].map(([ref, lanes]) => ({ ref, lanes: [...lanes] })),
326
334
  usage: { ...usage, elapsedMs: now() - started },
@@ -331,3 +339,26 @@ export const gatherProjectEvidence = async (ports, request) => {
331
339
  checkpoint: resumable ? encodeCheckpoint(checkpoint) : null,
332
340
  };
333
341
  };
342
+ /** The owner's own messages say nothing about who else is involved, so they are left out. */
343
+ const peopleOf = (items, senderOf) => {
344
+ const byKey = new Map();
345
+ for (const item of items) {
346
+ const sender = senderOf.get(item.ref);
347
+ if (!sender || sender.owner)
348
+ continue;
349
+ const key = sender.person ?? JSON.stringify([sender.provider, sender.id]);
350
+ const found = byKey.get(key) ?? {
351
+ person: sender.person,
352
+ name: sender.personName ?? sender.name ?? sender.id,
353
+ identities: [],
354
+ items: 0,
355
+ contexts: {},
356
+ };
357
+ byKey.set(key, found);
358
+ if (!found.identities.some((i) => i.provider === sender.provider && i.account === item.account && i.id === sender.id))
359
+ found.identities.push({ provider: sender.provider, account: item.account, id: sender.id });
360
+ found.items++;
361
+ found.contexts[item.context] = (found.contexts[item.context] ?? 0) + 1;
362
+ }
363
+ return [...byKey.values()].sort((a, b) => b.items - a.items || a.name.localeCompare(b.name));
364
+ };
@@ -1,4 +1,4 @@
1
- export type { ContextEvidenceItem, ContextEvidencePage, ContextEvidenceReader, ContextEvidenceRequest, ContextSource, } from "../context/evidence.js";
1
+ export type { ContextEvidenceItem, ContextEvidencePage, ContextEvidenceReader, ContextEvidenceRequest, ContextEvidenceSender, ContextSource, } from "../context/evidence.js";
2
2
  export { FACT_STATUSES, FACT_TYPES, MEMORY_KINDS, MEMORY_SCOPES, MEMORY_STATUSES, TASK_TYPES, WORK_TASK_TYPES, } from "../domain/knowledge.js";
3
3
  export type { TagConsolidationOptions, TagConsolidationPreview, TagConsolidationStore, } from "../domain/tag-consolidation.js";
4
4
  export type { TagRedirectIndex, TagRedirectResolution, TagRedirectSnapshot } from "../domain/tag-redirects.js";
@@ -100,9 +100,19 @@ export const contextEvidenceOver = ({ database }) => ({
100
100
  const bySender = senders === undefined
101
101
  ? ""
102
102
  : ` AND ${corpus.sender} IN (SELECT id FROM identities WHERE provider=a.provider AND external_id IN (${senders.map(() => "?").join(",")}))`;
103
- const base = `FROM ${corpus.table} n JOIN accounts a ON a.id=n.account_id ${corpus.join}
104
- WHERE n.account_id=? AND a.provider=? AND a.external_id=? AND n.deleted_at IS NULL
103
+ const from = `FROM ${corpus.table} n JOIN accounts a ON a.id=n.account_id ${corpus.join}`;
104
+ const where = `WHERE n.account_id=? AND a.provider=? AND a.external_id=? AND n.deleted_at IS NULL
105
105
  AND ${byChat}${bySender}`;
106
+ const base = `${from} ${where}`;
107
+ const author = corpus.sender === null
108
+ ? { join: "", columns: "NULL AS sender_id" }
109
+ : {
110
+ join: `LEFT JOIN identities si ON si.id=${corpus.sender} LEFT JOIN identity_links sl ON sl.identity_id=si.id
111
+ LEFT JOIN persons sp ON sp.id=sl.person_id`,
112
+ columns: `si.provider AS sender_provider,si.external_id AS sender_id,si.name AS sender_name,
113
+ sp.id AS sender_person,sp.name AS sender_person_name,
114
+ coalesce(sp.owner=1,0) OR (si.provider=a.provider AND si.external_id=a.external_id) AS sender_owner`,
115
+ };
106
116
  // A chat or sender read uses no word index, so words still waiting for it hide nothing.
107
117
  if (!searched)
108
118
  coverage.index = "ready";
@@ -135,7 +145,8 @@ export const contextEvidenceOver = ({ database }) => ({
135
145
  }
136
146
  const selection = `SELECT n.id,n.external_id,${corpus.chat} AS chat,a.provider,a.external_id AS account,
137
147
  ${corpus.title} AS title,${corpus.body} AS body,${corpus.url} AS url,${corpus.at} AS at,
138
- n.updated_at,${corpus.revision} AS revision,${corpus.timeKind} AS time_kind ${base}
148
+ n.updated_at,${corpus.revision} AS revision,${corpus.timeKind} AS time_kind,${author.columns}
149
+ ${from} ${author.join} ${where}
139
150
  AND ${corpus.at}>=? AND ${corpus.at}<?
140
151
  ${!searched ? "" : `AND n.id IN (SELECT rowid FROM ${corpus.index} WHERE ${corpus.index} MATCH ?)`}
141
152
  ${!searched ? "" : source.kind === "message" ? `AND n.normalized_text IS NOT NULL AND n.normalizer_version=${NORMALIZER_VERSION}` : `AND NOT EXISTS(SELECT 1 FROM ${source.kind === "mail" ? "email" : "document"}_index_pending p WHERE p.id=n.id AND p.indexable_type='${source.kind === "mail" ? "email" : "document"}')`}
@@ -198,6 +209,20 @@ export const contextEvidenceOver = ({ database }) => ({
198
209
  timeKind: row.time_kind,
199
210
  updatedAt: Number(row.updated_at),
200
211
  revision: row.revision == null ? null : Number(row.revision),
212
+ ...(corpus.sender === null
213
+ ? {}
214
+ : {
215
+ sender: row.sender_id == null
216
+ ? null
217
+ : {
218
+ provider: String(row.sender_provider),
219
+ id: String(row.sender_id),
220
+ name: row.sender_name == null ? null : String(row.sender_name),
221
+ person: row.sender_person == null ? null : `person:${row.sender_person}`,
222
+ personName: row.sender_person_name == null ? null : String(row.sender_person_name),
223
+ owner: Boolean(row.sender_owner),
224
+ },
225
+ }),
201
226
  })),
202
227
  };
203
228
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.416.0",
3
+ "version": "0.418.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",