|
|
|
|
|
-|
|
|
-|
|
|
+|
|
|
+|
|
|
## Known limitations
diff --git a/docs/concepts.md b/docs/concepts.md
index 77f5823..72dbdc0 100644
--- a/docs/concepts.md
+++ b/docs/concepts.md
@@ -9,8 +9,8 @@ Use exactly these words in UI text, docs and code. Do not invent synonyms (vault
| Word | Meaning |
| --- | --- |
| **Library** | A named set of items with one schema, one Connect link and one Access list. There are many libraries, not one global pile. |
-| **Item** | One entry in a library: name, labels, aliases, summary, and a markdown body or template fields. |
-| **Aliases** | Other words people use for an item ("phished", "suspicious email"). Search matches them like labels. Not shown as tags. |
+| **Item** | One entry in a library: name, labels, summary, and a markdown body or template fields. |
+| **Labels** | Free-form tags: categories ("security") and the other words people search with ("phished", "suspicious email"). |
| **Publish** | A person approves an item. Readers and Reader keys only ever see published items. |
| **Connect link** | A library's MCP address, with a secret token. One link reaches one library. The token is stored only as a SHA-256 hash, so the full link is shown once; to get a new one, rotate it. |
| **Reader** / **Publisher** | Per-library roles, granted under **Access**. |
@@ -55,7 +55,7 @@ Each Connect link is one library. Tools offered depend on the caller's role in t
| `search_shelf` | Reader+ | Published summaries across the library's shelf |
| `create_item` | Publisher | Save: Pending (Review Lock) or published (Review Unlock). With an `entity_key` that already exists in the library, updates that item instead (upsert). |
| `update_item` | Publisher | Change an item. Fields you leave out keep their value; `replace: true` replaces the whole item (omitted optional fields are cleared). Unknown fields are refused. |
-| `suggest_labels` | Publisher | Add or remove labels and aliases on a **published** item without touching its text: Pending for a person to approve (Review Lock) or live at once (Review Unlock) |
+| `suggest_labels` | Publisher | Add or remove labels on a **published** item without touching its text: Pending for a person to approve (Review Lock) or live at once (Review Unlock) |
| `delete_item` | Site admin | Hard-delete an item (Publishers archive instead) |
| `create_library` | Site admin | Create a library, optionally with a proposed table |
| `propose_table` / `propose_field` | Publisher | Design a library; a person approves the schema. After approval the tools pick up the new field on the next request; there is no need to reconnect. |
@@ -71,17 +71,17 @@ Each Connect link is one library. Tools offered depend on the caller's role in t
- **No match** from `list_items` (by `q` or `entity_key`) is a normal answer that says the item is not in the library, not a tool error.
**How `list_items q=` searches.**
-1. Every word must match (name, labels and aliases, summary, then body; the full-text index applies English stemming). Aliases score exactly like labels.
+1. Every word must match (name, labels, summary, then body; the full-text index applies English stemming).
2. If nothing matches every word, any word may match. A clear winner that matched on its name or labels, and on most of the words, is returned as the one answer. Weaker candidates come back as **"Did you mean"** suggestions, which the agent must open and check before citing.
-3. Still nothing: words that appear nowhere in the library's names, summaries, labels or aliases are corrected to the closest library word (1–2 typos), and steps 1–2 run again.
+3. Still nothing: words that appear nowhere in the library's names, summaries or labels are corrected to the closest library word (1–2 typos), and steps 1–2 run again.
-A word that is only a label several items share (for example `runbook`) returns those items as a list, not one of them as "the" answer. Questions with no word in common with the library are still refused. `search_shelf` falls back from the exact phrase to "every word somewhere in name, summary, labels or aliases".
+A word that is only a label several items share (for example `runbook`) returns those items as a list, not one of them as "the" answer. Questions with no word in common with the library are still refused. `search_shelf` falls back from the exact phrase to "every word somewhere in name, summary or labels".
-**Aliases and label suggestions.** Each item has an editable **Aliases** field (comma-separated, lower case, up to 20 of 40 characters). Aliases are part of the published copy, so changing them is an ordinary edit: under Review Lock it goes Pending and Readers keep searching the published aliases until Publish. Agents fix search misses with `suggest_labels` (`add_labels`, `remove_labels`, `add_aliases`, `remove_aliases`, optional `reason` and `expected_revision`):
+**Label suggestions.** Labels double as search words: label an incident runbook "phished" and "we think we got phished" finds it. Agents fix search misses with `suggest_labels` (`add_labels`, `remove_labels`, optional `reason` and `expected_revision`):
- It only works on published items (including ones with a Pending edit, which it joins). Name, summary and body are never changed; use `update_item` for drafts.
-- **Review Lock:** the item goes Pending. "What changed" lists **Labels** and **Aliases** before and after. A person approves with **Publish** or rejects with **Cancel**, which restores the published labels and aliases.
+- **Review Lock:** the item goes Pending. "What changed" lists **Labels** before and after. A person approves with **Publish** or rejects with **Cancel**, which restores the published labels.
- **Review Unlock:** the change is live at once.
-- Readers and Reader keys do not get the tool. Every call is audit-logged as `suggest_labels` (item id, resulting labels and aliases, reason). Approve and reject are logged by the existing publish and cancel events.
+- Readers and Reader keys do not get the tool. Every call is audit-logged as `suggest_labels` (item id, resulting labels, reason). Approve and reject are logged by the existing publish and cancel events.
- Citations follow the published copy: the version moves only when a suggestion is published (or saved under Review Unlock).
**Citations.** Every published item that `list_items`, `open_item` or `search_shelf` returns carries a citation for its **published** copy (never a Pending draft). A full item ends with:
diff --git a/migrations/026_aliases_into_labels.sql b/migrations/026_aliases_into_labels.sql
new file mode 100644
index 0000000..1ae0ce5
--- /dev/null
+++ b/migrations/026_aliases_into_labels.sql
@@ -0,0 +1,94 @@
+-- Aliases fold into labels. Search already matched and ranked aliases exactly
+-- like labels, and both went through Review Lock the same way, so one list
+-- does the job: "phished" or "suspicious email" is simply a label.
+-- Each item keeps its labels first, then any alias it did not already have
+-- (lower case, trimmed, first-seen order). Nothing is dropped: an item that
+-- ends up over the 20-label limit keeps every word, and the next edit asks
+-- for fewer. The published copies get the same merge without counting as a
+-- new publish.
+
+CREATE OR REPLACE FUNCTION mcpencode_merge_labels(labels JSONB, aliases JSONB) RETURNS JSONB
+LANGUAGE sql IMMUTABLE AS $$
+ SELECT COALESCE(jsonb_agg(label ORDER BY first_pos), '[]'::jsonb)
+ FROM (
+ SELECT label, MIN(pos) AS first_pos
+ FROM (
+ SELECT lower(btrim(value)) AS label, pos
+ FROM jsonb_array_elements_text(
+ (CASE WHEN jsonb_typeof(labels) = 'array' THEN labels ELSE '[]'::jsonb END) ||
+ (CASE WHEN jsonb_typeof(aliases) = 'array' THEN aliases ELSE '[]'::jsonb END)
+ ) WITH ORDINALITY AS t(value, pos)
+ ) cleaned
+ WHERE label <> ''
+ GROUP BY label
+ ) merged
+$$;
+
+UPDATE items SET labels = mcpencode_merge_labels(labels, aliases)
+WHERE jsonb_typeof(aliases) = 'array' AND jsonb_array_length(aliases) > 0;
+
+ALTER TABLE items DISABLE TRIGGER items_published_version;
+ALTER TABLE items DISABLE TRIGGER items_review_by;
+
+UPDATE items
+SET published_snapshot = (
+ jsonb_set(published_snapshot::jsonb, '{labels}',
+ mcpencode_merge_labels(published_snapshot::jsonb -> 'labels', published_snapshot::jsonb -> 'aliases')) - 'aliases'
+)::text
+WHERE published_snapshot IS NOT NULL
+ AND pg_input_is_valid(published_snapshot, 'jsonb')
+ AND jsonb_typeof(published_snapshot::jsonb) = 'object'
+ AND published_snapshot::jsonb -> 'aliases' IS NOT NULL;
+
+UPDATE items
+SET previous_published_snapshot = (
+ jsonb_set(previous_published_snapshot::jsonb, '{labels}',
+ mcpencode_merge_labels(previous_published_snapshot::jsonb -> 'labels', previous_published_snapshot::jsonb -> 'aliases')) - 'aliases'
+)::text
+WHERE previous_published_snapshot IS NOT NULL
+ AND pg_input_is_valid(previous_published_snapshot, 'jsonb')
+ AND jsonb_typeof(previous_published_snapshot::jsonb) = 'object'
+ AND previous_published_snapshot::jsonb -> 'aliases' IS NOT NULL;
+
+ALTER TABLE items ENABLE TRIGGER items_published_version;
+ALTER TABLE items ENABLE TRIGGER items_review_by;
+
+DROP FUNCTION mcpencode_merge_labels(JSONB, JSONB);
+
+-- The view loses its aliases column (CREATE OR REPLACE cannot drop one).
+DROP VIEW IF EXISTS items_live;
+CREATE VIEW items_live AS
+SELECT
+ i.id,
+ i.library_id,
+ i.entity_key,
+ CASE WHEN i.status = 'draft' AND i.published_live
+ THEN (i.published_snapshot::jsonb ->> 'name') ELSE i.name END AS name,
+ CASE WHEN i.status = 'draft' AND i.published_live
+ THEN COALESCE(i.published_snapshot::jsonb -> 'labels', '[]'::jsonb) ELSE i.labels END AS labels,
+ CASE WHEN i.status = 'draft' AND i.published_live
+ THEN (i.published_snapshot::jsonb ->> 'summary') ELSE i.summary END AS summary,
+ CASE WHEN i.status = 'draft' AND i.published_live
+ THEN (i.published_snapshot::jsonb ->> 'body') ELSE i.body END AS body,
+ CASE WHEN i.status = 'draft' AND i.published_live
+ THEN (i.published_snapshot::jsonb ->> 'details') ELSE i.details END AS details,
+ CASE WHEN i.status = 'draft' AND i.published_live
+ THEN 'published' ELSE i.status END AS status,
+ i.published_snapshot,
+ i.previous_published_snapshot,
+ (i.status = 'draft' AND i.published_live) AS has_pending_changes,
+ i.created_by,
+ i.updated_by,
+ i.created_at,
+ CASE WHEN i.status = 'draft' AND i.published_live
+ THEN COALESCE((i.published_snapshot::jsonb ->> 'published_at')::numeric::bigint, i.created_at)
+ ELSE i.updated_at END AS updated_at,
+ CASE WHEN i.status = 'draft' AND i.published_live
+ THEN
+ setweight(to_tsvector('english', coalesce(i.published_snapshot::jsonb ->> 'name', '')), 'A') ||
+ setweight(to_tsvector('english', coalesce(i.published_snapshot::jsonb ->> 'summary', '')), 'B') ||
+ setweight(to_tsvector('english', coalesce(i.published_snapshot::jsonb ->> 'body', '')), 'C')
+ ELSE i.search_tsv END AS search_tsv
+FROM items i;
+
+ALTER TABLE items DROP COLUMN IF EXISTS aliases;
diff --git a/src/db/items.ts b/src/db/items.ts
index 560bae4..3afed3d 100644
--- a/src/db/items.ts
+++ b/src/db/items.ts
@@ -50,7 +50,6 @@ type ListHit = {
entity_key: string | null;
labels: string | string[];
/** Present only on a Pending review list. */
- aliases?: string | string[] | null;
summary: string;
status: string;
updated_at: number;
@@ -90,7 +89,6 @@ function toSummary(
{
name: row.name,
labels,
- aliases: parseLabels(row.aliases ?? []),
summary: row.summary,
body: row.body ?? "",
details: typeof row.details === "string" ? row.details : null,
@@ -158,7 +156,7 @@ export async function listItems(
if (needle) {
const pattern = `%${escapeLike(needle)}%`;
where.push(
- "(name ILIKE ? ESCAPE '\\' OR summary ILIKE ? ESCAPE '\\' OR (labels::text || ' ' || aliases::text) ILIKE ? ESCAPE '\\')",
+ "(name ILIKE ? ESCAPE '\\' OR summary ILIKE ? ESCAPE '\\' OR labels::text ILIKE ? ESCAPE '\\')",
);
params.push(pattern, pattern, pattern);
}
@@ -172,7 +170,7 @@ export async function listItems(
const base = "id, library_id, name, entity_key, labels, summary, status, updated_at";
const columns =
(options.review
- ? `${base}, aliases, body, details, published_snapshot`
+ ? `${base}, body, details, published_snapshot`
: options.includeDetails
? `${base}, details`
: base) + pendingColumn;
@@ -255,8 +253,6 @@ export async function getLibraryItem(
}
type ItemSave = ItemInput & {
- /** Search aliases. Omitted on update keeps the stored aliases. */
- aliases?: string[];
summary?: string;
details?: string | null;
entity_key?: string | null;
@@ -282,7 +278,6 @@ export async function addDraftItem(
libraryId: string,
input: ItemWriteInput,
actorSub: string,
- extra: { aliases?: string[] } = {},
): Promise<{ ok: true; item: ItemRow } | { ok: false; message: string }> {
try {
return await db.transaction(async (tx) => {
@@ -291,8 +286,7 @@ export async function addDraftItem(
const saved = itemForLibrary(library.schema, input);
if (!saved.ok) return saved;
const status = library.writeMode === "direct" ? "published" : "draft";
- const toSave = extra.aliases ? { ...saved.item, aliases: extra.aliases } : saved.item;
- const item = await createItem(tx, toSave, actorSub, libraryId, { status });
+ const item = await createItem(tx, saved.item, actorSub, libraryId, { status });
return { ok: true, item };
});
} catch (error) {
@@ -328,7 +322,6 @@ export async function createItem(
? snapshotFromItem({
name: input.name,
labels: input.labels,
- aliases: input.aliases ?? [],
summary,
body: input.body,
details: input.details ?? null,
@@ -338,15 +331,14 @@ export async function createItem(
await runSql(
db,
`INSERT INTO items (
- id, library_id, name, entity_key, labels, aliases, summary, body, details, status, published_snapshot,
+ id, library_id, name, entity_key, labels, summary, body, details, status, published_snapshot,
created_by, updated_by, created_at, updated_at
- ) VALUES (?, ?, ?, ?, ?::jsonb, ?::jsonb, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ ) VALUES (?, ?, ?, ?, ?::jsonb, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id,
library_id,
input.name,
keyed.key,
JSON.stringify(input.labels),
- JSON.stringify(input.aliases ?? []),
summary,
input.body,
input.details ?? null,
@@ -429,7 +421,6 @@ export async function updateItem(
? { ok: true as const, key: existing.entity_key }
: entityKeyForSave(input.entity_key);
if (!keyed.ok) throw new Error(keyed.message);
- const aliases = input.aliases ?? existing.aliases ?? [];
if (writeMode === "direct") {
// Review Unlock: any successful save is live. Snapshot tracks the live copy.
@@ -438,7 +429,6 @@ export async function updateItem(
const nextFields = {
name: input.name,
labels: input.labels,
- aliases,
summary,
body: input.body,
details: input.details ?? null,
@@ -454,7 +444,7 @@ export async function updateItem(
changed = await allRows<{ id: string }>(
db,
`UPDATE items
- SET name = ?, entity_key = ?, labels = ?::jsonb, aliases = ?::jsonb, summary = ?, body = ?, details = ?,
+ SET name = ?, entity_key = ?, labels = ?::jsonb, summary = ?, body = ?, details = ?,
status = 'published', published_snapshot = ?, previous_published_snapshot = ?,
published_live = false, updated_by = ?, updated_at = ?
WHERE id = ? AND library_id = ?${guard}
@@ -462,7 +452,6 @@ export async function updateItem(
input.name,
keyed.key,
JSON.stringify(input.labels),
- JSON.stringify(aliases),
summary,
input.body,
input.details ?? null,
@@ -504,7 +493,7 @@ export async function updateItem(
changed = await allRows<{ id: string }>(
db,
`UPDATE items
- SET name = ?, entity_key = ?, labels = ?::jsonb, aliases = ?::jsonb, summary = ?, body = ?, details = ?,
+ SET name = ?, entity_key = ?, labels = ?::jsonb, summary = ?, body = ?, details = ?,
status = CASE WHEN status = 'published' THEN 'draft' ELSE status END,
published_live = CASE WHEN status = 'published' THEN ? ELSE published_live END,
updated_by = ?, updated_at = ?
@@ -513,7 +502,6 @@ export async function updateItem(
input.name,
keyed.key,
JSON.stringify(input.labels),
- JSON.stringify(aliases),
summary,
input.body,
input.details ?? null,
@@ -590,7 +578,6 @@ export async function publishItem(
const nextFields = {
name: existing.name,
labels: existing.labels,
- aliases: existing.aliases ?? [],
summary: existing.summary,
body: existing.body,
details: existing.details,
@@ -661,12 +648,11 @@ export async function cancelPublishItem(
await runSql(
tx,
`UPDATE items
- SET name = ?, labels = ?::jsonb, aliases = ?::jsonb, summary = ?, body = ?, details = ?,
+ SET name = ?, labels = ?::jsonb, summary = ?, body = ?, details = ?,
status = 'published', published_live = false, updated_by = ?, updated_at = ?
WHERE id = ? AND status = 'draft'`,
snapshot.name,
JSON.stringify(snapshot.labels),
- JSON.stringify(snapshot.aliases ?? []),
snapshot.summary,
snapshot.body,
snapshot.details,
@@ -872,12 +858,11 @@ export async function revertToPublished(
await runSql(
tx,
`UPDATE items
- SET name = ?, labels = ?::jsonb, aliases = ?::jsonb, summary = ?, body = ?, details = ?,
+ SET name = ?, labels = ?::jsonb, summary = ?, body = ?, details = ?,
status = 'draft', published_live = ?, updated_by = ?, updated_at = ?
WHERE id = ?`,
snapshot.name,
JSON.stringify(snapshot.labels),
- JSON.stringify(snapshot.aliases ?? []),
snapshot.summary,
snapshot.body,
snapshot.details,
diff --git a/src/db/label-suggestions.ts b/src/db/label-suggestions.ts
index 72bb255..7122bf5 100644
--- a/src/db/label-suggestions.ts
+++ b/src/db/label-suggestions.ts
@@ -1,11 +1,12 @@
/**
- * Agents recommend labels and aliases for a published item without touching
- * its text. The suggestion is an ordinary edit of the working row that changes
- * only labels and aliases, so it follows the library's write mode exactly like
- * update_item:
+ * Agents recommend labels for a published item without touching its text:
+ * categories ("security") and the other words people search with ("phished",
+ * "suspicious email") are both labels. The suggestion is an ordinary edit of
+ * the working row that changes only labels, so it follows the library's write
+ * mode exactly like update_item:
* - Review Lock: the item goes Pending, the published copy stays live for
* Readers, and a person approves (Publish) or rejects (Cancel) it on the
- * item page, where "What changed" lists the Labels / Aliases change.
+ * item page, where "What changed" lists the Labels change.
* - Review Unlock: the change is live at once (a new published version).
* Citations follow the published copy, so the version only moves on Publish
* (or an Unlock save), as with any other edit.
@@ -19,12 +20,10 @@ import { labelsEqual } from "./query-helpers.ts";
export type LabelSuggestionInput = {
add_labels?: string[];
remove_labels?: string[];
- add_aliases?: string[];
- remove_aliases?: string[];
};
export type LabelSuggestionResult =
- | { ok: true; item: ItemRow; changed: true; labels: string[]; aliases: string[] }
+ | { ok: true; item: ItemRow; changed: true; labels: string[] }
| { ok: true; item: ItemRow; changed: false }
| { ok: false; reason: "not_found" | "not_published" | "empty"; message: string };
@@ -33,21 +32,11 @@ function normalize(values: string[]): string[] {
return parseLabelField(values.map((value) => value.replace(/,/g, " ")).join(","));
}
-function applyChange(current: string[], add: string[] | undefined, remove: string[] | undefined): string[] {
- const drop = new Set(normalize(remove ?? []));
- const kept = current.filter((value) => !drop.has(value.trim().toLowerCase()));
- return normalize([...kept, ...normalize(add ?? [])]);
-}
-
-/** Labels and aliases after the suggestion, without saving. */
-export function plannedLabels(
- item: Pick${esc(input.error)}
` : ""} @@ -249,7 +248,6 @@ export function itemPage(input: { `; const labels = input.item.labels.map((label) => `${esc(label)}`).join(""); - const aliases = (input.item.aliases ?? []).map((alias) => esc(alias)).join(", "); const flash = noticeMessage(input.notice, status); const review = itemReviewBlock(input.item, input.library.schema); const unlockHint = @@ -317,7 +315,6 @@ export function itemPage(input: { : "" } ${labels ? `` : ""} - ${aliases ? `` : ""} ${input.freshness ?? ""} ${review} @@ -390,7 +387,6 @@ export function editPage(input: { summaryRequired: false, }) } - ${aliasesField((input.item.aliases ?? []).join(", "))} diff --git a/src/http/routes/items.ts b/src/http/routes/items.ts index eb72fdb..8cc94b0 100644 --- a/src/http/routes/items.ts +++ b/src/http/routes/items.ts @@ -34,7 +34,7 @@ import { import { itemToMarkdown, markdownExportFilename } from "../../items/export-markdown.ts"; import { parseTableValues, tableControl, tableParts } from "../../items/table-proposal.ts"; import { renderMarkdown } from "../../markdown/render.ts"; -import { formAliases, parseLibraryItem, requireCsrf, sessionAccess, tokensMatch } from "../helpers.ts"; +import { parseLibraryItem, requireCsrf, sessionAccess, tokensMatch } from "../helpers.ts"; import { editPage, errorPage, itemPage } from "../pages.ts"; import { readItemUpload } from "../upload.ts"; import { itemFreshness } from "../../db/freshness.ts"; @@ -362,9 +362,7 @@ export function registerItemRoutes(app: express.Express, ctx: AppRouteContext): const wasPublished = item.status === "published"; let row; try { - const aliases = formAliases(req.body); - const input = aliases === undefined ? parsed.data : { ...parsed.data, aliases }; - row = await updateItem(db, req.params.id, input, access.sub, { + row = await updateItem(db, req.params.id, parsed.data, access.sub, { expectedRevision: formRevision(req.body), }); } catch (error) { diff --git a/src/http/routes/libraries.ts b/src/http/routes/libraries.ts index 81945a1..ea7a24b 100644 --- a/src/http/routes/libraries.ts +++ b/src/http/routes/libraries.ts @@ -41,7 +41,6 @@ import { libraryInputSchema } from "../../items/schema.ts"; import { writeModeLabel } from "../pages/schema-choice.ts"; import { formString, - formAliases, formToItemWrite, postedCustomFields, libraryTemplateFromForm, @@ -1234,7 +1233,6 @@ export function registerLibraryRoutes(app: express.Express, ctx: AppRouteContext ctxLib.library.id, formToItemWrite(req.body, ctxLib.library.schema), access.sub, - { aliases: formAliases(req.body) }, ); if (!result.ok) { const shell = await publisherShell(db, access); diff --git a/src/items/export-markdown.ts b/src/items/export-markdown.ts index 115724e..efb356d 100644 --- a/src/items/export-markdown.ts +++ b/src/items/export-markdown.ts @@ -62,12 +62,6 @@ export function itemToMarkdown(item: ItemRow, library: LibraryRow): string { if (labels.length > 0) { chunks.push(`Labels: ${labels.join(", ")}`, ""); } - const aliases = Array.isArray(item.aliases) - ? item.aliases.filter((alias): alias is string => typeof alias === "string" && alias.trim() !== "") - : []; - if (aliases.length > 0) { - chunks.push(`Aliases: ${aliases.join(", ")}`, ""); - } const starter = library.schema?.starter ?? "general"; const fields = Array.isArray(library.schema?.fields) ? library.schema.fields : []; diff --git a/src/items/review.ts b/src/items/review.ts index b43f8f1..db48fc0 100644 --- a/src/items/review.ts +++ b/src/items/review.ts @@ -24,7 +24,6 @@ export type ItemReview = export type ReviewSource = { name: string; labels: string[]; - aliases?: string[]; summary: string; body: string; details: string | null; @@ -124,7 +123,6 @@ export function itemReview( const fields: ReviewField[] = []; pushChange(fields, "Name", snapshot.name, current.name, false); pushChange(fields, "Labels", labelsText(snapshot.labels), labelsText(current.labels), false); - pushChange(fields, "Aliases", labelsText(snapshot.aliases ?? []), labelsText(current.aliases ?? []), false); pushChange(fields, "Summary", snapshot.summary, current.summary, false); const structured = structuredChanges(schema, snapshot.details, current.details); diff --git a/src/markdown/render.ts b/src/markdown/render.ts index 35b12fe..7f1ae84 100644 --- a/src/markdown/render.ts +++ b/src/markdown/render.ts @@ -62,8 +62,6 @@ export function labeledItemData(item: { entity_key?: string | null; name: string; labels: string[]; - /** Search aliases. Printed only when the item has some. */ - aliases?: string[]; summary?: string; body: string; files?: { name: string; content_type: string; byte_size: number }[]; @@ -95,7 +93,6 @@ export function labeledItemData(item: { item.revision === undefined ? "" : `revision: ${item.revision}`, `name: ${item.name}`, `labels: ${item.labels.join(", ") || "(none)"}`, - item.aliases && item.aliases.length > 0 ? `aliases: ${item.aliases.join(", ")}` : "", item.summary ? `summary: ${item.summary}` : "", "body:", item.body, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index ae7f080..e0e0704 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -339,7 +339,6 @@ export function buildMcpServer( entity_key: item.entity_key, name: item.name, labels: item.labels, - aliases: item.aliases ?? [], summary: item.summary, body: bodyWithoutSummary(item.body, item.summary), files, @@ -907,17 +906,15 @@ export function buildMcpServer( server.registerTool( "suggest_labels", { - title: "Suggest labels or aliases", + title: "Suggest labels", description: library.writeMode === "direct" - ? "Recommend labels and aliases (other words people search for, e.g. \"phished\" for an incident runbook) for a PUBLISHED item. Only labels and aliases change; name, summary and body are untouched. This library is Review Unlock, so the change is live immediately. Use when a question should have found this item but did not." - : "Recommend labels and aliases (other words people search for, e.g. \"phished\" for an incident runbook) for a PUBLISHED item. Only labels and aliases change; name, summary and body are untouched. The suggestion goes to Pending: Readers keep the published copy until a person approves it (Publish) or rejects it (Cancel) in the publisher. Use when a question should have found this item but did not.", + ? "Recommend labels for a PUBLISHED item: categories, or other words people search with (e.g. \"phished\" for an incident runbook). Only labels change; name, summary and body are untouched. This library is Review Unlock, so the change is live immediately. Use when a question should have found this item but did not." + : "Recommend labels for a PUBLISHED item: categories, or other words people search with (e.g. \"phished\" for an incident runbook). Only labels change; name, summary and body are untouched. The suggestion goes to Pending: Readers keep the published copy until a person approves it (Publish) or rejects it (Cancel) in the publisher. Use when a question should have found this item but did not.", inputSchema: z.object({ id: z.string().uuid(), add_labels: labelList, remove_labels: labelList, - add_aliases: labelList, - remove_aliases: labelList, reason: z.string().trim().max(300).optional().describe("Why, in one line (e.g. the question that missed)."), expected_revision: z.number().int().optional(), }), @@ -942,15 +939,15 @@ export function buildMcpServer( } if (!result.changed) { await audit(true, `${id} no change`); - return ok(`No change: the item already has those labels and aliases.\n${await itemWire(result.item)}`); + return ok(`No change: the item already has those labels.\n${await itemWire(result.item)}`); } await audit( true, - JSON.stringify({ id, labels: result.labels, aliases: result.aliases, reason: reason ?? "" }), + JSON.stringify({ id, labels: result.labels, reason: reason ?? "" }), ); const head = library.writeMode === "direct" - ? "Saved. This library is Review Unlock, so the new labels and aliases are live now." + ? "Saved. This library is Review Unlock, so the new labels are live now." : "Suggested. The change is Pending: Readers and agents still see the published copy. A person must approve it (Publish) or reject it (Cancel) in the publisher."; return ok(`${head}\n${await itemWire(result.item)}`); } catch (error) { diff --git a/test/item-aliases.test.ts b/test/label-suggestions.test.ts similarity index 70% rename from test/item-aliases.test.ts rename to test/label-suggestions.test.ts index f8153ad..0b4c56b 100644 --- a/test/item-aliases.test.ts +++ b/test/label-suggestions.test.ts @@ -1,5 +1,6 @@ -/** Per-item aliases in search, and agent label/alias suggestions under Review Lock / Unlock. */ +/** Search words as labels, and agent label suggestions under Review Lock / Unlock. */ import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; import { test } from "node:test"; import type { AuthInfo } from "@modelcontextprotocol/server"; import { citationsFor } from "../src/db/citations.ts"; @@ -16,7 +17,6 @@ import { updateItem, } from "../src/db/queries.ts"; import type { Database } from "../src/db/types.ts"; -import { formAliases } from "../src/http/helpers.ts"; import { itemReview } from "../src/items/review.ts"; import { buildMcpServer } from "../src/mcp/server.ts"; import { withTestDatabase } from "./db.ts"; @@ -78,19 +78,19 @@ async function seedRunbooks(db: Database, writeMode: "reviewed" | "direct" = "re const PHISHED = "we think we got phished, what do we do"; -test("aliases are matched like labels, ranked, and typo-tolerant", async () => { +test("search words are plain labels: matched, ranked and typo-tolerant", async () => { await withTestDatabase(async (db) => { const { libraryId, incident, remote } = await seedRunbooks(db); - // Without aliases nothing in the library says "phished". + // Before the labels nothing in the library says "phished". assert.equal((await lookupItems(db, { library_id: libraryId, q: PHISHED }, reader)).kind, "none"); const row = await getItem(db, incident, { publishedOnly: false }); assert.ok(row); - await updateItem(db, incident, { name: row.name, body: row.body, labels: row.labels, aliases: ["phished", "phishing", "suspicious email"] }, "alice"); + await updateItem(db, incident, { name: row.name, body: row.body, labels: [...row.labels, "phished", "phishing", "suspicious email"] }, "alice"); await publishItem(db, incident, "alice"); const r = await getItem(db, remote, { publishedOnly: false }); assert.ok(r); - await updateItem(db, remote, { name: r.name, body: r.body, labels: r.labels, aliases: ["office network", "work from home", "wfh"] }, "alice"); + await updateItem(db, remote, { name: r.name, body: r.body, labels: [...r.labels, "office network", "work from home", "wfh"] }, "alice"); await publishItem(db, remote, "alice"); const hit = await lookupItems(db, { library_id: libraryId, q: PHISHED }, reader); @@ -101,19 +101,19 @@ test("aliases are matched like labels, ranked, and typo-tolerant", async () => { assert.equal(office.kind, "item"); assert.equal(office.kind === "item" && office.item.id, remote); - // Multi-word alias words match on their own too. + // Multi-word labels match too. const email = await lookupItems(db, { library_id: libraryId, q: "suspicious email" }, reader); assert.equal(email.kind === "item" && email.item.id, incident); - // Typo against an alias word is corrected from the library's vocabulary. + // A typo against a label word is corrected from the library's vocabulary. const typo = await lookupItems(db, { library_id: libraryId, q: "phisshed" }, reader); assert.equal(typo.kind, "item"); assert.equal(typo.kind === "item" && typo.item.id, incident); assert.equal(typo.kind === "item" && typo.corrected, "phished"); - // Readers see aliases on the item they open. + // Readers see the labels on the item they open. const opened = await getItem(db, incident, { ...reader, libraryId }); - assert.deepEqual(opened?.aliases, ["phished", "phishing", "suspicious email"]); + assert.deepEqual(opened?.labels, ["runbook", "security", "phished", "phishing", "suspicious email"]); }); }); @@ -125,8 +125,7 @@ test("suggest_labels under Review Lock: Pending until a person publishes; Reader const res = await call(db, libraryId, "suggest_labels", { id: incident, - add_aliases: ["Phished", "phishing", "suspicious email", "phished"], - add_labels: ["phishing"], + add_labels: ["Phished", "phishing", "suspicious email", "phished"], reason: PHISHED, }); assert.equal(res.isError, false, res.text); @@ -135,24 +134,23 @@ test("suggest_labels under Review Lock: Pending until a person publishes; Reader const working = await getItem(db, incident, { publishedOnly: false }); assert.ok(working); assert.equal(working.status, "draft"); - assert.deepEqual(working.aliases, ["phished", "phishing", "suspicious email"]); - assert.deepEqual(working.labels, ["runbook", "security", "phishing"]); + assert.deepEqual(working.labels, ["runbook", "security", "phished", "phishing", "suspicious email"]); // Text untouched. assert.equal(working.name, before.name); assert.equal(working.summary, before.summary); assert.equal(working.body, before.body); - // Review shows the label and alias change only. + // Review shows the label change only. const review = itemReview(working, working.published_snapshot, (await getLibrary(db, libraryId))!.schema); assert.equal(review.kind, "changed"); assert.deepEqual( review.kind === "changed" ? review.fields.map((field) => field.label) : [], - ["Labels", "Aliases"], + ["Labels"], ); - // Readers: still the published copy, and search does not use the pending alias. + // Readers: still the published copy, and search does not use the pending labels. const live = await getItem(db, incident, { ...reader, libraryId }); - assert.deepEqual(live?.aliases, []); + assert.deepEqual(live?.labels, ["runbook", "security"]); assert.equal((await lookupItems(db, { library_id: libraryId, q: PHISHED }, reader)).kind, "none"); assert.equal((await citationsFor(db, [incident])).get(incident)?.version, 1); @@ -171,16 +169,15 @@ test("suggest_labels under Review Lock: Pending until a person publishes; Reader }); }); -test("rejecting a suggestion (Cancel) restores the published labels and aliases", async () => { +test("rejecting a suggestion (Cancel) restores the published labels", async () => { await withTestDatabase(async (db) => { const { libraryId, incident } = await seedRunbooks(db); - const res = await call(db, libraryId, "suggest_labels", { id: incident, add_aliases: ["phished"], remove_labels: ["security"] }); + const res = await call(db, libraryId, "suggest_labels", { id: incident, add_labels: ["phished"], remove_labels: ["security"] }); assert.equal(res.isError, false, res.text); const cancelled = await cancelPublishItem(db, incident, "dana"); assert.equal(cancelled.ok, true); const row = await getItem(db, incident, { publishedOnly: false }); assert.equal(row?.status, "published"); - assert.deepEqual(row?.aliases, []); assert.deepEqual(row?.labels, ["runbook", "security"]); assert.equal((await citationsFor(db, [incident])).get(incident)?.version, 1); }); @@ -189,12 +186,13 @@ test("rejecting a suggestion (Cancel) restores the published labels and aliases" test("suggest_labels under Review Unlock is live at once and bumps the published version", async () => { await withTestDatabase(async (db) => { const { libraryId, remote } = await seedRunbooks(db, "direct"); - const res = await call(db, libraryId, "suggest_labels", { id: remote, add_aliases: ["office network", "wfh"] }); + const res = await call(db, libraryId, "suggest_labels", { id: remote, add_labels: ["office network", "wfh"] }); assert.equal(res.isError, false, res.text); assert.match(res.text, /Review Unlock/); - assert.match(res.text, /^aliases: office network, wfh$/m); + assert.match(res.text, /^labels: policy, office network, wfh$/m); + assert.doesNotMatch(res.text, /aliases/); const live = await getItem(db, remote, { ...reader, libraryId }); - assert.deepEqual(live?.aliases, ["office network", "wfh"]); + assert.deepEqual(live?.labels, ["policy", "office network", "wfh"]); assert.equal((await citationsFor(db, [remote])).get(remote)?.version, 2); }); }); @@ -203,14 +201,14 @@ test("suggest_labels refuses drafts, empty and stale requests; Readers do not ge await withTestDatabase(async (db) => { const { libraryId, incident } = await seedRunbooks(db); const draft = await createItem(db, { name: "Draft", body: "x", labels: [] }, "alice", libraryId); - const notLive = await call(db, libraryId, "suggest_labels", { id: draft.id, add_aliases: ["x"] }); + const notLive = await call(db, libraryId, "suggest_labels", { id: draft.id, add_labels: ["x"] }); assert.equal(notLive.isError, true); assert.match(notLive.text, /published items/); const empty = await call(db, libraryId, "suggest_labels", { id: incident }); assert.equal(empty.isError, true); - const stale = await call(db, libraryId, "suggest_labels", { id: incident, add_aliases: ["x"], expected_revision: 1 }); + const stale = await call(db, libraryId, "suggest_labels", { id: incident, add_labels: ["x"], expected_revision: 1 }); assert.equal(stale.isError, true); assert.match(stale.text, /Conflict/); @@ -228,30 +226,51 @@ test("suggest_labels refuses drafts, empty and stale requests; Readers do not ge }); }); -test("update_item and the edit form keep aliases unless they send new ones", async () => { +test("suggest_labels takes labels only", async () => { await withTestDatabase(async (db) => { - const { libraryId, incident } = await seedRunbooks(db); - const row = await getItem(db, incident, { publishedOnly: false }); - assert.ok(row); - await updateItem(db, incident, { name: row.name, body: row.body, labels: row.labels, aliases: ["phished"] }, "alice"); - const again = await getItem(db, incident, { publishedOnly: false }); - assert.ok(again); - await updateItem(db, incident, { name: again.name, body: `${again.body} More.`, labels: again.labels }, "alice"); - assert.deepEqual((await getItem(db, incident, { publishedOnly: false }))?.aliases, ["phished"]); - assert.ok(libraryId); - - assert.equal(formAliases({ name: "x" }), undefined); - assert.deepEqual(formAliases({ aliases: " Phished, phishing ,, PHISHED" }), ["phished", "phishing"]); - assert.deepEqual(formAliases({ aliases: "" }), []); + const { libraryId } = await seedRunbooks(db); + const schema = (await tools(db, libraryId, "publisher")).suggest_labels as unknown as { inputSchema?: { shape?: Record