@mnemoverse/mcp-memory-server 0.8.0 → 0.8.1

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/dist/render.js CHANGED
@@ -10,11 +10,27 @@
10
10
  * shapes without atom_id/created_at degrade gracefully: those parts of
11
11
  * the line are simply omitted.
12
12
  */
13
+ import { MAX_DOMAIN_TAG_LITERAL, exactLiteral } from "./names.js";
13
14
  /**
14
- * Sanitize a string for inline interpolation into tool output (CN-032:
15
- * hostile connectors choose their own agent_name). Charset/cap identical
16
- * to index.ts's safeInline — re-exported here so both render paths share
17
- * one implementation.
15
+ * Sanitize a string for inline interpolation into tool output (CN-032: hostile
16
+ * connectors choose their own agent_name). The single implementation, imported
17
+ * by index.ts and injected into src/scope.ts for the machine-shaped room
18
+ * fields (address, room_id — core charset-validates both; this is a defensive
19
+ * second pass).
20
+ *
21
+ * NOT for anything the reader must reproduce. This is lossy and non-injective
22
+ * by design — non-ASCII becomes spaces, whitespace is collapsed and trimmed,
23
+ * the tail is cut — so two distinct values can come out as one string and a
24
+ * padded value comes out as its clean twin. For a domain name, an id, or
25
+ * anything else that gets compared or sent back, use src/names.ts
26
+ * (`exactLiteral`), which prints exactly or refuses to print.
27
+ *
28
+ * NOT for room NAMES either, as of 0.8.1 (`roomNamePhrase`, src/names.ts).
29
+ * They are display-only, but this sanitiser did not make them harmless so much
30
+ * as it made them wrong: "проект" rendered "(unnamed room)", and "Zoë" was
31
+ * quoted as "Zo" — a different name presented as the name. The exact literal
32
+ * is single-line with quotes and invisibles escaped, so it carries the same
33
+ * anti-injection property without the renaming.
18
34
  */
19
35
  export function safeInline(s, cap = 200) {
20
36
  return (s ?? "")
@@ -36,6 +52,38 @@ export function formatAuthorTag(p) {
36
52
  return "";
37
53
  return p.is_external ? ` [by ${who} · external]` : ` [by ${who}]`;
38
54
  }
55
+ /**
56
+ * ` @"domain"` — which store the memory actually came from, printed so it can
57
+ * be re-sent.
58
+ *
59
+ * Absent before 0.8.1, and its absence was a real trap: an unscoped search for
60
+ * a common name returned five different people's "Maria Chen" from five
61
+ * different projects, ranked together, with nothing on the line to tell them
62
+ * apart (dogfood, 2026-08-07). A reader could not answer "is this mine?"
63
+ * without re-querying scoped.
64
+ *
65
+ * The tag went out through `safeInline`, which defeated the one job it has. It
66
+ * is a DISAMBIGUATOR between stores whose names differ by a space or a
67
+ * character set, and the sanitiser erases exactly those differences:
68
+ * `"проект:acme"` and `"план:acme"` both printed `@:acme`, merging the two
69
+ * stores the tag exists to separate. Worse, `" general"` sanitised to `general`
70
+ * and was then SUPPRESSED by the check below, so a memory from a padded store
71
+ * rendered as if it came from the caller's default bucket. Now the value is
72
+ * printed as an exact JSON literal (src/names.ts) and only the literal string
73
+ * `"general"` is suppressed.
74
+ *
75
+ * Still omitted when the server doesn't send a domain, and for the caller's own
76
+ * default bucket — labelling everything `@"general"` would be noise on the
77
+ * common case. When the literal will not fit, the tag says so rather than
78
+ * disappearing: an absent tag means "the default bucket", which would be a
79
+ * false statement about the store.
80
+ */
81
+ export function formatDomainTag(domain) {
82
+ if (!domain || domain === "general")
83
+ return "";
84
+ const exact = exactLiteral(domain, MAX_DOMAIN_TAG_LITERAL);
85
+ return exact ? ` @${exact.literal}` : ` @(domain cannot be printed exactly)`;
86
+ }
39
87
  /**
40
88
  * ` · 2026-08-01 21:04Z` — minute-precision UTC, compact enough for a line
41
89
  * tail, precise enough to order a same-day room conversation by eye.
@@ -52,19 +100,40 @@ export function formatDateTag(createdAt) {
52
100
  }
53
101
  /**
54
102
  * One memory_read result line:
55
- * `N. [82%] content (concepts) [by X] · 2026-08-01 21:04Z\n id: <uuid>`
103
+ * `N. content (concepts) @"domain" [by X] · 2026-08-01 21:04Z\n id: <uuid>`
56
104
  *
57
105
  * The id sits on its own indented line: full-width (feedback/delete need
58
106
  * the EXACT id, truncation would break them) without crowding the content
59
107
  * line a model actually reads.
108
+ *
109
+ * NO SCORE, as of 0.8.1 (Eduard's call). The line used to lead with the
110
+ * server's `relevance` rendered as a percentage, and that was wrong twice
111
+ * over:
112
+ *
113
+ * - It read as confidence and wasn't. The engine's relevance floor
114
+ * (`min_relevance`, default 0.3) is low enough that a query about
115
+ * something never stored still returns near-neighbours — dogfooding got a
116
+ * real person's profile at "73%" for a question about someone fictional,
117
+ * and month-old notes at "73%" for "what's new". A number that in practice
118
+ * never bottoms out cannot say "I don't know", but a reader takes it as
119
+ * though it can. (An earlier draft of this comment claimed there was NO
120
+ * floor. There is one; it is simply too low to mean anything — review,
121
+ * 2026-08-08.)
122
+ * - It wasn't a percentage of anything. Positive feedback pushes the score
123
+ * above 1.0, so reads showed "112%".
124
+ *
125
+ * Rank order still carries the ranking, which is the part that is true. A
126
+ * genuinely dependable signal is worth surfacing and is on the 0.9 list —
127
+ * this is a deliberate removal until there is one, not a decision that
128
+ * scores are useless. `relevance` stays on the type because the server sends
129
+ * it; we simply do not put it in front of a reader yet.
60
130
  */
61
131
  export function formatReadItem(item, index) {
62
- const relevance = ((item?.relevance ?? 0) * 100).toFixed(0);
63
132
  const content = item?.content ?? "(empty)";
64
133
  const concepts = Array.isArray(item?.concepts) && item.concepts.length > 0
65
134
  ? ` (${item.concepts.join(", ")})`
66
135
  : "";
67
- const head = `${index + 1}. [${relevance}%] ${content}${concepts}${formatAuthorTag(item?.provenance)}${formatDateTag(item?.created_at)}`;
136
+ const head = `${index + 1}. ${content}${concepts}${formatDomainTag(item?.domain)}${formatAuthorTag(item?.provenance)}${formatDateTag(item?.created_at)}`;
68
137
  return item?.atom_id ? `${head}\n id: ${item.atom_id}` : head;
69
138
  }
70
139
  /**
@@ -77,15 +146,35 @@ export function formatRecentItem(item, index) {
77
146
  ? ` (${item.concepts.join(", ")})`
78
147
  : "";
79
148
  const date = formatDateTag(item?.created_at).replace(/^ · /, "");
80
- const head = `${index + 1}. ${date ? `[${date}] ` : ""}${content}${concepts}${formatAuthorTag(item?.provenance)}`;
149
+ const head = `${index + 1}. ${date ? `[${date}] ` : ""}${content}${concepts}${formatDomainTag(item?.domain)}${formatAuthorTag(item?.provenance)}`;
81
150
  return item?.atom_id ? `${head}\n id: ${item.atom_id}` : head;
82
151
  }
83
- /** Full feed page: items newest-first + how to continue / that it's over. */
152
+ /**
153
+ * Full feed page: items newest-first + how to continue / that it's over.
154
+ *
155
+ * Returns the BODY only — no escape legend. The legend belongs to the final
156
+ * answer, and the caller (src/index.ts) appends it AFTER capResult: appended
157
+ * here it sat before the cap, which truncates from the end, so the one
158
+ * sentence explaining that an escape like \u200b is ONE character was the
159
+ * first thing cut from every page long enough to be capped (truth F6,
160
+ * 2026-08-08). Once-per-answer still holds — withDomainEscapeLegend is
161
+ * at-most-once by construction (src/names.ts).
162
+ */
84
163
  export function formatRecentPage(items, nextCursor) {
85
164
  const lines = items.map((it, i) => formatRecentItem(it, i));
86
165
  // Defense-in-depth (CN-032 posture): the cursor is server-supplied and
87
166
  // interpolated into instructional text — only echo it when it matches the
88
- // opaque urlsafe-base64 shape ours always has.
167
+ // opaque urlsafe-base64 shape ours always has. Core mirrors the same regex on
168
+ // its side, so a cursor that fails here is a contract violation, not a value.
169
+ //
170
+ // KNOWN DEFECT, not fixed in 0.8.1 and recorded in the CHANGELOG: this one
171
+ // boolean decides an existence claim about a different thing. `false` means
172
+ // EITHER "the server said there is nothing older" OR "the server said there IS
173
+ // more and I refuse to print the token", and both print `(end of feed —
174
+ // nothing older)`. That is could-not-render spelled exactly like does-not-
175
+ // exist — the collision this release is about, surviving in the one place the
176
+ // fix did not reach. It needs a third branch and therefore a new sentence,
177
+ // which is a behaviour change.
89
178
  const cursorOk = nextCursor != null && /^[A-Za-z0-9_=-]{1,512}$/.test(nextCursor);
90
179
  const tail = cursorOk
91
180
  ? `\n\nMore older entries exist — pass cursor: ${nextCursor}`
@@ -1 +1 @@
1
- {"version":3,"file":"render.js","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"render.js","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Request bodies, built by pure functions so the wire contract can be TESTED.
3
+ *
4
+ * Why this module exists. 0.8.1 is a patch, and its rule is that wording may
5
+ * change but where data is written or read from may not. The branch broke that
6
+ * rule twice and neither break was caught:
7
+ *
8
+ * 1. A `trim()` on the way out normalised past a deliberate 400-guard in
9
+ * core, so a padded room address would have written into a shared room
10
+ * visible to other accounts.
11
+ * 2. The revert of that trim dropped `|| undefined` on the read path, so
12
+ * `domain: ""` became `WHERE domain = ''` — a store that cannot exist —
13
+ * turning a search of every domain into a guaranteed miss.
14
+ *
15
+ * The guard test at the time grepped src/index.ts for the ABSENCE of a trim.
16
+ * That cannot catch a DELETED coercion, which is exactly what shipped: 60 tests
17
+ * green with the divergence in place (reviews, 2026-08-08). A denylist over
18
+ * source text is not a contract; a function whose output you can compare is.
19
+ *
20
+ * Rule for changing anything here: if a body changes for ANY input, that is a
21
+ * MINOR release, however obviously correct the change looks. The tests pin the
22
+ * whole matrix — whitespace, non-breaking and zero-width spaces, padded room
23
+ * addresses, "0", ":" — because every one of those was a real finding.
24
+ *
25
+ * Deliberately NOT normalising: core matches domains byte-for-byte and rejects
26
+ * non-canonical room addresses on purpose. Cleaning input here silently moves
27
+ * data and defeats a security guard. Normalisation belongs in 0.9.0, together
28
+ * with memory_delete_domain, with room addresses exempt, and with zero-width
29
+ * characters handled — `trim()` does not strip those.
30
+ */
31
+ export interface ReadArgs {
32
+ query: string;
33
+ top_k?: number;
34
+ domain?: string;
35
+ order_by?: "relevance" | "recency";
36
+ since?: string;
37
+ until?: string;
38
+ exclude_author?: string;
39
+ }
40
+ export interface RecentArgs {
41
+ domain?: string;
42
+ since?: string;
43
+ until?: string;
44
+ exclude_author?: string;
45
+ limit?: number;
46
+ cursor?: string;
47
+ }
48
+ export interface WriteArgs {
49
+ content: string;
50
+ concepts?: string[];
51
+ domain?: string;
52
+ }
53
+ /**
54
+ * The scope actually searched: what core receives, and therefore the only value
55
+ * any message about the result may describe.
56
+ *
57
+ * `|| undefined` rather than a null check, because core filters on
58
+ * `domain is not None` — an empty string would become a real, impossible
59
+ * filter. Everything else passes through untouched.
60
+ */
61
+ export declare function searchedScope(domain?: string): string | undefined;
62
+ /** memory_read. The temporal keys are omitted entirely when unused so the body
63
+ * stays byte-identical for callers that never pass them (#404). */
64
+ export declare function readRequestBody(a: ReadArgs): Record<string, unknown>;
65
+ /** memory_list_recent. */
66
+ export declare function recentRequestBody(a: RecentArgs): Record<string, unknown>;
67
+ /** memory_write. `"general"` is the server-side default made explicit, and is
68
+ * what 0.8.0 sent — NOT a normalisation of the caller's value. */
69
+ export declare function writeRequestBody(a: WriteArgs): Record<string, unknown>;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Request bodies, built by pure functions so the wire contract can be TESTED.
3
+ *
4
+ * Why this module exists. 0.8.1 is a patch, and its rule is that wording may
5
+ * change but where data is written or read from may not. The branch broke that
6
+ * rule twice and neither break was caught:
7
+ *
8
+ * 1. A `trim()` on the way out normalised past a deliberate 400-guard in
9
+ * core, so a padded room address would have written into a shared room
10
+ * visible to other accounts.
11
+ * 2. The revert of that trim dropped `|| undefined` on the read path, so
12
+ * `domain: ""` became `WHERE domain = ''` — a store that cannot exist —
13
+ * turning a search of every domain into a guaranteed miss.
14
+ *
15
+ * The guard test at the time grepped src/index.ts for the ABSENCE of a trim.
16
+ * That cannot catch a DELETED coercion, which is exactly what shipped: 60 tests
17
+ * green with the divergence in place (reviews, 2026-08-08). A denylist over
18
+ * source text is not a contract; a function whose output you can compare is.
19
+ *
20
+ * Rule for changing anything here: if a body changes for ANY input, that is a
21
+ * MINOR release, however obviously correct the change looks. The tests pin the
22
+ * whole matrix — whitespace, non-breaking and zero-width spaces, padded room
23
+ * addresses, "0", ":" — because every one of those was a real finding.
24
+ *
25
+ * Deliberately NOT normalising: core matches domains byte-for-byte and rejects
26
+ * non-canonical room addresses on purpose. Cleaning input here silently moves
27
+ * data and defeats a security guard. Normalisation belongs in 0.9.0, together
28
+ * with memory_delete_domain, with room addresses exempt, and with zero-width
29
+ * characters handled — `trim()` does not strip those.
30
+ */
31
+ /**
32
+ * The scope actually searched: what core receives, and therefore the only value
33
+ * any message about the result may describe.
34
+ *
35
+ * `|| undefined` rather than a null check, because core filters on
36
+ * `domain is not None` — an empty string would become a real, impossible
37
+ * filter. Everything else passes through untouched.
38
+ */
39
+ export function searchedScope(domain) {
40
+ return domain || undefined;
41
+ }
42
+ /** memory_read. The temporal keys are omitted entirely when unused so the body
43
+ * stays byte-identical for callers that never pass them (#404). */
44
+ export function readRequestBody(a) {
45
+ return {
46
+ query: a.query,
47
+ top_k: a.top_k || 5,
48
+ domain: searchedScope(a.domain),
49
+ include_associations: true,
50
+ ...(a.order_by ? { order_by: a.order_by } : {}),
51
+ ...(a.since ? { since: a.since } : {}),
52
+ ...(a.until ? { until: a.until } : {}),
53
+ ...(a.exclude_author ? { exclude_author: a.exclude_author } : {}),
54
+ };
55
+ }
56
+ /** memory_list_recent. */
57
+ export function recentRequestBody(a) {
58
+ return {
59
+ domain: searchedScope(a.domain),
60
+ since: a.since || undefined,
61
+ until: a.until || undefined,
62
+ exclude_author: a.exclude_author || undefined,
63
+ limit: a.limit || 20,
64
+ cursor: a.cursor || undefined,
65
+ };
66
+ }
67
+ /** memory_write. `"general"` is the server-side default made explicit, and is
68
+ * what 0.8.0 sent — NOT a normalisation of the caller's value. */
69
+ export function writeRequestBody(a) {
70
+ return {
71
+ content: a.content,
72
+ concepts: a.concepts || [],
73
+ domain: a.domain || "general",
74
+ };
75
+ }
76
+ //# sourceMappingURL=requests.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"requests.js","sourceRoot":"","sources":["../src/requests.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AA2BH;;;;;;;GAOG;AACH,MAAM,UAAU,aAAa,CAAC,MAAe;IAC3C,OAAO,MAAM,IAAI,SAAS,CAAC;AAC7B,CAAC;AAED;mEACmE;AACnE,MAAM,UAAU,eAAe,CAAC,CAAW;IACzC,OAAO;QACL,KAAK,EAAE,CAAC,CAAC,KAAK;QACd,KAAK,EAAE,CAAC,CAAC,KAAK,IAAI,CAAC;QACnB,MAAM,EAAE,aAAa,CAAC,CAAC,CAAC,MAAM,CAAC;QAC/B,oBAAoB,EAAE,IAAI;QAC1B,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC/C,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACtC,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACtC,GAAG,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,EAAE,cAAc,EAAE,CAAC,CAAC,cAAc,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAClE,CAAC;AACJ,CAAC;AAED,0BAA0B;AAC1B,MAAM,UAAU,iBAAiB,CAAC,CAAa;IAC7C,OAAO;QACL,MAAM,EAAE,aAAa,CAAC,CAAC,CAAC,MAAM,CAAC;QAC/B,KAAK,EAAE,CAAC,CAAC,KAAK,IAAI,SAAS;QAC3B,KAAK,EAAE,CAAC,CAAC,KAAK,IAAI,SAAS;QAC3B,cAAc,EAAE,CAAC,CAAC,cAAc,IAAI,SAAS;QAC7C,KAAK,EAAE,CAAC,CAAC,KAAK,IAAI,EAAE;QACpB,MAAM,EAAE,CAAC,CAAC,MAAM,IAAI,SAAS;KAC9B,CAAC;AACJ,CAAC;AAED;kEACkE;AAClE,MAAM,UAAU,gBAAgB,CAAC,CAAY;IAC3C,OAAO;QACL,OAAO,EAAE,CAAC,CAAC,OAAO;QAClB,QAAQ,EAAE,CAAC,CAAC,QAAQ,IAAI,EAAE;QAC1B,MAAM,EAAE,CAAC,CAAC,MAAM,IAAI,SAAS;KAC9B,CAAC;AACJ,CAAC"}
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Search-scope honesty — the note that turns "nothing found" from an assertion
3
+ * about the WORLD into a statement about where we actually looked.
4
+ *
5
+ * Why this module exists (incident 2026-08-07). Two agents lost a working day
6
+ * to the same silent failure: a message was written into a shared room, and an
7
+ * unscoped `memory_list_recent` answered "Nothing new since your watermark."
8
+ * The write was fine, the index was fine, and the read was fine — an UNSCOPED
9
+ * read simply never covers rooms.
10
+ *
11
+ * That is not a filter oversight; it is how the store is built. A room is a
12
+ * separate storage tenant (core ADR-019): with no `domain`, the request runs
13
+ * against the caller's OWN org, and room atoms live in the room's org. So the
14
+ * unscoped feed cannot see them and never could.
15
+ *
16
+ * The defect is therefore not the scoping — it is the WORDING. "Nothing new"
17
+ * is a claim about absence, and absence is only knowable within the scope you
18
+ * searched. An empty result from an unscoped read must therefore say what it did
19
+ * not cover: which rooms went unsearched and how to read them, that every room
20
+ * it has is archived and reachable by no path, or that it could not find out.
21
+ * Silence is correct in exactly one case — the caller has no rooms, so nothing
22
+ * was missed — and that case is a distinct state below rather than the falsy
23
+ * spelling of the other three.
24
+ *
25
+ * Kept out of index.ts so the copy can be unit-tested without booting the
26
+ * stdio transport, matching render.ts and teaching.ts.
27
+ */
28
+ /**
29
+ * How to NAME the scope inside a sentence, so the sentence is true on its own
30
+ * rather than corrected by a paragraph underneath it.
31
+ *
32
+ * "Nothing new since your watermark." was left untouched by the first two
33
+ * passes of this release — the very sentence this module's header names as the
34
+ * lie that cost two agents a day. A note was appended to it instead, so the
35
+ * assembled answer read as two voices: one asserting you were caught up, the
36
+ * next saying that assertion was meaningless. The sibling branch in memory_read
37
+ * had it right all along by naming its filters inside the sentence (review,
38
+ * 2026-08-08).
39
+ *
40
+ * The name inside that sentence then went through safeInline, which made the
41
+ * sentence false in the case that matters most: a read on `" engineering"` — the
42
+ * padded second store this release is about — answered `Nothing in
43
+ * "engineering"`, naming a DIFFERENT store, and a whitespace-only scope printed
44
+ * as `""`, i.e. as no scope at all. It is now the exact literal (src/names.ts),
45
+ * and when a name cannot be reproduced the sentence names nothing rather than
46
+ * naming something else.
47
+ *
48
+ * A room address stays unnamed ("that room") — unchanged, and deliberately: the
49
+ * address is already in the caller's hand, and this sentence has no need to
50
+ * print another principal's display string. Where a room's NAME is printed at
51
+ * all ({@link liveRoomsNote} below, the room tools in index.ts) it goes through
52
+ * `roomNamePhrase` (src/names.ts) — exactly, or declared unprintable — never
53
+ * through the lossy sanitiser, which renamed "проект" to "(unnamed room)".
54
+ *
55
+ * Lives here rather than in index.ts so the sentence can be unit-tested: this is
56
+ * the module about saying where we looked, and index.ts opens a stdio transport
57
+ * on import.
58
+ */
59
+ export declare function scopeLabel(searched: string | undefined): string;
60
+ /**
61
+ * "Nothing new since your watermark" is also what you get for a watermark in
62
+ * the FUTURE — a mixed-up timezone or a bad relative-date calculation reads
63
+ * exactly like a clean bill of health (dogfood, 2026-08-07). If the caller's
64
+ * `since` is ahead of now, say so and show the current time; that is the whole
65
+ * diagnosis, and the caller cannot reach it from "nothing new".
66
+ *
67
+ * The explaining clause claims a fact about TIME, never about any store. It
68
+ * used to read "nothing has been written after it YET" — an absence claim over
69
+ * EVERY store, printed directly above the scope disclosure admitting a room
70
+ * went unsearched, and premised on an admittedly-approximate client clock
71
+ * (review, 2026-08-08). The window is empty because it asks for entries newer
72
+ * than a moment that has not happened; what any store holds is not this note's
73
+ * to assert.
74
+ *
75
+ * `nowMs` is injected so the note is testable without freezing the clock.
76
+ */
77
+ export declare function futureSinceNote(since: string | undefined, nowMs: number): string;
78
+ /**
79
+ * The disclosure for the {@link NamedScope} arm `no-such-domain`: the caller
80
+ * scoped a read to a domain that is not in `/memory/stats.domains`.
81
+ *
82
+ * WHAT IT DOES, which is not what it was called. Until 0.8.1 this was
83
+ * `nearestDomainNote`, and the name outlived the behaviour twice over: the
84
+ * "nearest"/prefix guess it was named for was deleted as unsound (see the body),
85
+ * and even before that it was never a nearest-neighbour search. What remains is
86
+ * two branches, and neither is a proximity search:
87
+ *
88
+ * 1. an EXACT case-insensitive twin, when one exists in the known list and
89
+ * both names can be printed reproducibly — the casing slip that silently
90
+ * forks a namespace, which is the one miss a reader can act on
91
+ * (dogfood, 2026-08-07);
92
+ * 2. otherwise a NAME-FREE statement that no store OF THE CALLER'S OWN
93
+ * carries that exact name — bounded to its evidence: `/memory/stats.domains`
94
+ * covers the caller's own org bucket only, so rooms and other principals'
95
+ * stores are invisible to it and the sentence may not claim them (review,
96
+ * 2026-08-08; the destructive sibling in index.ts already said "in your
97
+ * own store"). It is always safe to say and must never go quiet — silence
98
+ * here is byte-identical to "the store is there, your query merely
99
+ * missed".
100
+ *
101
+ * Deliberately conservative: only case differs. A whitespace or zero-width twin
102
+ * (`" engineering"` beside `"engineering"`) is NOT diagnosed here — extending the
103
+ * rule is a behaviour decision, and `memory_stats` closes that loop by printing
104
+ * both names exactly. Recorded in CHANGELOG's "Known and NOT fixed here".
105
+ */
106
+ export declare function noSuchDomainNote(domain: string, knownDomains: readonly string[]): string;
107
+ /** The subset of a room record anything here reads. Every field is normalised
108
+ * to a string-or-absent by {@link asRoom} before it is stored. */
109
+ export interface RoomSummary {
110
+ room_id?: string;
111
+ name?: string;
112
+ address?: string;
113
+ role?: string;
114
+ scope?: string;
115
+ archived?: boolean;
116
+ }
117
+ /**
118
+ * Why a probe could not answer. Both are "we do not know", and they are kept
119
+ * apart because the reader's next move differs: a fetch failure is worth
120
+ * retrying, a shape this client cannot read is not.
121
+ */
122
+ export type UncheckedReason = "fetch-failed" | "unrecognised-shape";
123
+ /**
124
+ * What we know about the shared rooms an UNSCOPED read did not cover.
125
+ *
126
+ * `note` is the disclosure such a read must append, and it is present on
127
+ * exactly the states that have something to disclose. Other consumers of the
128
+ * same payload (memory_list_rooms) read the facts and ignore it.
129
+ */
130
+ export type RoomScope = {
131
+ /** The probe answered, and the caller has no rooms at all. Nothing was
132
+ * missed, so there is nothing to disclose — hence no `note` field. */
133
+ readonly state: "none";
134
+ } | {
135
+ /** At least one room can still be read by passing its address. */
136
+ readonly state: "live";
137
+ /** Every room in the list, live and archived — for memory_list_rooms. */
138
+ readonly rooms: readonly RoomSummary[];
139
+ readonly live: readonly RoomSummary[];
140
+ readonly archived: number;
141
+ readonly note: string;
142
+ } | {
143
+ /** Rooms exist and NONE of them can be read: every one is archived. */
144
+ readonly state: "archived-only";
145
+ readonly rooms: readonly RoomSummary[];
146
+ readonly archived: number;
147
+ readonly note: string;
148
+ } | {
149
+ /** We could not look. Not evidence in either direction. */
150
+ readonly state: "unknown";
151
+ readonly reason: UncheckedReason;
152
+ readonly note: string;
153
+ };
154
+ /** What we know about the ONE store a scoped read named. */
155
+ export type NamedScope = {
156
+ /** The scope exists and we are in it, so an empty result is a real miss. */
157
+ readonly state: "present";
158
+ readonly name: string;
159
+ } | {
160
+ /** No store with that byte-exact name in the caller's own bucket. */
161
+ readonly state: "no-such-domain";
162
+ readonly name: string;
163
+ readonly note: string;
164
+ } | {
165
+ /** That address is not among the rooms this client can see. */
166
+ readonly state: "no-such-room";
167
+ readonly name: string;
168
+ readonly note: string;
169
+ } | {
170
+ /** We could not look — so nothing may be concluded about the name. */
171
+ readonly state: "unchecked";
172
+ readonly name: string;
173
+ readonly probed: "domains" | "rooms";
174
+ readonly reason: UncheckedReason;
175
+ readonly note: string;
176
+ };
177
+ /**
178
+ * Where a read looked, and what we know about what it therefore did not cover.
179
+ *
180
+ * The two halves are ONE value on purpose. A scoped read must not probe the room
181
+ * list (a room address is checked against the rooms, a domain against the
182
+ * domains — CodeRabbit #65), and an unscoped read must always probe it; as two
183
+ * parameters those invariants were merely unwritten, and both were broken at
184
+ * different points in this release. Here a `named` scope has no room knowledge
185
+ * to be wrong about, and an `own-domains` scope cannot exist without it.
186
+ */
187
+ export type ReadScope = {
188
+ readonly kind: "named";
189
+ readonly named: NamedScope;
190
+ } | {
191
+ readonly kind: "own-domains";
192
+ readonly rooms: RoomScope;
193
+ };
194
+ /** The disclosure for either kind of scope — "" only where the type says there
195
+ * is genuinely nothing to disclose. */
196
+ export declare function readScopeNote(scope: ReadScope): string;
197
+ /**
198
+ * Classify a `/memory/rooms` body. Total: every payload maps to exactly one
199
+ * state, and a payload that is not a list of rooms maps to `unknown` — never to
200
+ * `none`, which is the substitution that turned a contract violation into "you
201
+ * have no rooms".
202
+ */
203
+ export declare function classifyRooms(payload: unknown, sanitize: (s: string | undefined | null) => string): RoomScope;
204
+ /** Fetch the caller's rooms and classify them. Never throws — a failure is a
205
+ * state, not an exception, because every caller has to render it. */
206
+ export declare function probeRoomScope(fetchRooms: () => Promise<unknown>, sanitize: (s: string | undefined | null) => string): Promise<RoomScope>;
207
+ /**
208
+ * Build the {@link ReadScope} for one read: where it looked, and what we know
209
+ * about what it therefore did not cover. One GET per call — the room list for
210
+ * an unscoped read or an `xroom:` address, `/memory/stats` for a plain domain
211
+ * — and callers reach it on zero-result paths only. That is MORE probing than
212
+ * 0.8.0 did, not "exactly as before" (an earlier version of this comment):
213
+ * 0.8.0 made no probe at all on a domain-scoped, room-scoped or filtered read
214
+ * and none anywhere in the feed; only the plain unscoped read probed stats.
215
+ * The added reads are disclosed in the 0.8.1 CHANGELOG entry, rate cost
216
+ * included.
217
+ *
218
+ * `searched` must be the value that went over the wire — `searchedScope(domain)`
219
+ * — so that the diagnosis and the request can never describe different stores.
220
+ * A falsy value takes the own-domains path because that is what the request did:
221
+ * core filters on `domain is not None`, and `searchedScope` drops `""`.
222
+ */
223
+ export declare function probeReadScope(searched: string | undefined, fetchStats: () => Promise<unknown>, fetchRooms: () => Promise<unknown>, sanitize: (s: string | undefined | null) => string): Promise<ReadScope>;