stylegallery 0.1.14 → 0.1.16
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/README.md +15 -8
- package/consumer-reference/agent-native/v2/material-registry.json +1 -1
- package/consumer-reference/agent-native/v2/search-embeddings.json +793 -0
- package/log.md +18 -0
- package/package.json +8 -2
- package/scripts/agent-native/v2/material-search-v3.mjs +100 -18
- package/scripts/agent-native/v2/search-embeddings.mjs +224 -0
- package/scripts/compiler/mcp.mjs +3 -2
- package/scripts/stylegallery-cli.mjs +10 -5
- package/scripts/stylegallery-setup.mjs +32 -8
package/log.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
## 2026-10-19
|
|
4
4
|
|
|
5
|
+
Understood as: release `stylegallery@0.1.16`, which ships hybrid search from PR #68: BM25F fused with an English embedding model so everyday wording finds documents that use different words.
|
|
6
|
+
|
|
7
|
+
- `stylegallery search` and the MCP `search` tool take `--mode hybrid|lexical` (`mode`), default `hybrid`: weighted reciprocal rank fusion (k 60, semantic weight 0.5) of BM25F and bge-small-en-v1.5 (quantized ONNX, 34 MB). The result reports the `mode` that ran and a `mode_reason` on fallback.
|
|
8
|
+
- The model is not packaged. The first hybrid search downloads it from a pinned revision into `~/.cache/stylegallery/models/` and checks its SHA-256; it runs on `onnxruntime-web`, an optional dependency. Offline (`STYLEGALLERY_OFFLINE=1`), without the dependency, or for Korean without `query_en`, search warns once and ranks with BM25F alone.
|
|
9
|
+
- Document vectors ship precomputed in `consumer-reference/agent-native/v2/search-embeddings.json`; `generate-search-embeddings.mjs --check --vectors` runs in the Search evaluation workflow.
|
|
10
|
+
- Search evaluation adds 20 vocabulary-mismatch questions (111 total). Hit@5 lexical to hybrid: overall 0.892 to 0.937, held-out 0.867 to 0.933, mismatch 0.50 to 0.70; must-pass 35/35 in both modes.
|
|
11
|
+
|
|
12
|
+
Consumer reference: not_applicable
|
|
13
|
+
Consumer reference reason: This release changes search ranking tooling and selects no consumer-reference profile or record.
|
|
14
|
+
|
|
15
|
+
Understood as: release `stylegallery@0.1.15`, which ships OmO support for `stylegallery setup` from PR #66.
|
|
16
|
+
|
|
17
|
+
- `stylegallery setup` adds `--agent omo`, detected by `~/.omo` or `omo` on PATH and included by default and in `all`. It registers `mcpServers.stylegallery` (`npx -y --package stylegallery stylegallery-mcp`) in `<agentDir>/mcp.json`, where agentDir is `$OMO_CODING_AGENT_DIR` or `~/.omo/agent`, with the same backup, `--force`, and idempotence rules as Claude Code and Codex. An identical hand-made entry is reported unchanged.
|
|
18
|
+
- The skill copy in `~/.agents/skills/stylegallery-search/` is shared by Codex and OmO: it is written once, and the second agent is reported with `shared_with`. `--uninstall` keeps the shared skill (`kept`) while another detected agent that uses it is not part of the same uninstall run.
|
|
19
|
+
|
|
20
|
+
Consumer reference: not_applicable
|
|
21
|
+
Consumer reference reason: This release adds agent setup tooling and selects no consumer-reference profile or record.
|
|
22
|
+
|
|
5
23
|
Understood as: release `stylegallery@0.1.14`, which ships `stylegallery setup` from PR #64 so an installed package can connect Claude Code and Codex in one command.
|
|
6
24
|
|
|
7
25
|
- `stylegallery setup` copies `skills/stylegallery-search/SKILL.md` to `~/.claude/skills/stylegallery-search/` and `~/.agents/skills/stylegallery-search/` and registers `npx -y --package stylegallery stylegallery-mcp` as user-scope `mcpServers.stylegallery` in `~/.claude.json` and `[mcp_servers.stylegallery]` in `~/.codex/config.toml`. Options: `--dry-run`, `--agent claude|codex|all`, `--only skill|mcp`, `--uninstall`, `--force`. Reruns change nothing, modified config files are backed up as `<file>.bak-stylegallery-<timestamp>`, other settings are preserved, and a different same-name entry is left alone without `--force`. Sources: https://code.claude.com/docs/en/mcp, https://code.claude.com/docs/en/skills, https://developers.openai.com/codex/mcp, https://developers.openai.com/codex/skills, read 2026-10-19.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stylegallery",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
4
4
|
"description": "A governed gallery of portable interface knowledge with knowledge CLIs, website compilation, and MCP servers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"consumer-reference/agent-native/v2/schema/admission-policy.schema.json",
|
|
51
51
|
"consumer-reference/agent-native/v2/schema/material-manifest.schema.json",
|
|
52
52
|
"consumer-reference/agent-native/v2/schema/material-record.schema.json",
|
|
53
|
+
"consumer-reference/agent-native/v2/search-embeddings.json",
|
|
53
54
|
"consumer-reference/contract.md",
|
|
54
55
|
"consumer-reference/index.md",
|
|
55
56
|
"design-engineering/component-contract.md",
|
|
@@ -265,6 +266,7 @@
|
|
|
265
266
|
"scripts/agent-native/v2/material-queries.mjs",
|
|
266
267
|
"scripts/agent-native/v2/material-registry.mjs",
|
|
267
268
|
"scripts/agent-native/v2/material-search-v3.mjs",
|
|
269
|
+
"scripts/agent-native/v2/search-embeddings.mjs",
|
|
268
270
|
"scripts/compiler/README.md",
|
|
269
271
|
"scripts/compiler/cli.mjs",
|
|
270
272
|
"scripts/compiler/mcp.mjs",
|
|
@@ -339,7 +341,8 @@
|
|
|
339
341
|
"stylegallery": "node scripts/sg-entry.mjs",
|
|
340
342
|
"test:search": "node --test scripts/test-stylegallery-search.mjs",
|
|
341
343
|
"test:setup": "node --test scripts/test-stylegallery-setup.mjs",
|
|
342
|
-
"eval:search": "node scripts/search-eval.mjs --gate"
|
|
344
|
+
"eval:search": "node scripts/agent-native/v2/generate-search-embeddings.mjs --check && node scripts/search-eval.mjs --gate",
|
|
345
|
+
"generate:search-embeddings": "node scripts/agent-native/v2/generate-search-embeddings.mjs"
|
|
343
346
|
},
|
|
344
347
|
"devDependencies": {
|
|
345
348
|
"@playwright/test": "1.61.0",
|
|
@@ -356,5 +359,8 @@
|
|
|
356
359
|
},
|
|
357
360
|
"overrides": {
|
|
358
361
|
"brace-expansion": "5.0.9"
|
|
362
|
+
},
|
|
363
|
+
"optionalDependencies": {
|
|
364
|
+
"onnxruntime-web": "1.30.0"
|
|
359
365
|
}
|
|
360
366
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
// Search v3: readable IDs, BM25F field ranking
|
|
2
|
-
//
|
|
1
|
+
// Search v3: readable IDs, BM25F field ranking fused with embedding similarity
|
|
2
|
+
// (hybrid mode), section-aware read. Deterministic with stable tie-breaks; when
|
|
3
|
+
// the embedding model is unavailable, search falls back to BM25F alone.
|
|
3
4
|
import { deepFreeze } from "../canonical-json.mjs";
|
|
4
5
|
import { MaterialQueryError, materialCorpus, tokenizeMaterialText } from "./material-queries.mjs";
|
|
6
|
+
import { EMBEDDING_MODEL, documentTextHash, loadEmbedder, loadStoredEmbeddings } from "./search-embeddings.mjs";
|
|
5
7
|
|
|
6
8
|
export const SEARCH_SCHEMA_VERSION = "3.0";
|
|
7
9
|
export const SEARCH_DEFAULT_LIMIT = 5;
|
|
@@ -24,6 +26,9 @@ export const SEARCH_FIELDS = deepFreeze({
|
|
|
24
26
|
ko: { w: 1.5, b: 0.3 },
|
|
25
27
|
});
|
|
26
28
|
export const SEARCH_K1 = 1.2;
|
|
29
|
+
export const SEARCH_MODES = Object.freeze(["hybrid", "lexical"]);
|
|
30
|
+
// Weighted reciprocal rank fusion of the BM25F and embedding rankings.
|
|
31
|
+
export const SEARCH_FUSION = deepFreeze({ method: "RRF", k: 60, lexical_weight: 1, semantic_weight: 0.5, depth: 50 });
|
|
27
32
|
|
|
28
33
|
const STOPWORDS = new Set(("a an and are as at be by can do does for from how i in into is it its me my of on or should "
|
|
29
34
|
+ "so that the this to use using what when where which with you your").split(" "));
|
|
@@ -275,7 +280,7 @@ function bestSnippet(document, terms) {
|
|
|
275
280
|
|
|
276
281
|
function validateSearchInput(input) {
|
|
277
282
|
if (!input || typeof input !== "object" || Array.isArray(input)) fail("material_input_invalid", "input must be an object");
|
|
278
|
-
const allowed = new Set(["query", "query_en", "limit", "paths_only", "response_format"]);
|
|
283
|
+
const allowed = new Set(["query", "query_en", "limit", "paths_only", "response_format", "mode"]);
|
|
279
284
|
if (Object.keys(input).some((key) => !allowed.has(key))) fail("material_input_unknown", "input contains an unsupported field");
|
|
280
285
|
for (const key of ["query", "query_en"]) {
|
|
281
286
|
const value = input[key];
|
|
@@ -289,12 +294,39 @@ function validateSearchInput(input) {
|
|
|
289
294
|
if (input.paths_only !== undefined && typeof input.paths_only !== "boolean") fail("material_paths_only_invalid", "paths_only must be a boolean");
|
|
290
295
|
const format = input.response_format ?? "concise";
|
|
291
296
|
if (!["concise", "detailed"].includes(format)) fail("search_response_format_invalid", "response_format must be concise or detailed");
|
|
292
|
-
|
|
297
|
+
const mode = input.mode ?? "hybrid";
|
|
298
|
+
if (!SEARCH_MODES.includes(mode)) fail("search_mode_invalid", "mode must be hybrid or lexical");
|
|
299
|
+
return { query: input.query, queryEn: input.query_en, limit, pathsOnly: input.paths_only === true, detailed: format === "detailed", mode };
|
|
293
300
|
}
|
|
294
301
|
|
|
295
|
-
/**
|
|
296
|
-
export function
|
|
297
|
-
|
|
302
|
+
/** The text embedded for a query: the English rewrite when given, else the query unless it is Korean. */
|
|
303
|
+
export function semanticQueryText(query, queryEn) {
|
|
304
|
+
if (queryEn) return queryEn;
|
|
305
|
+
return /[\p{Script=Hangul}]/u.test(query) ? null : query;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Prepare the semantic half of a hybrid search. Resolves to { mode, semantic, reason }:
|
|
310
|
+
* mode "lexical" with a reason whenever hybrid was requested but cannot run.
|
|
311
|
+
*/
|
|
312
|
+
export async function prepareSemantic(index, query, queryEn, requestedMode = "hybrid", options = {}) {
|
|
313
|
+
if (requestedMode === "lexical") return { mode: "lexical", semantic: null };
|
|
314
|
+
const text = semanticQueryText(query, queryEn);
|
|
315
|
+
if (text === null) return { mode: "lexical", semantic: null, reason: "Korean query without query_en; the embedding model reads English only" };
|
|
316
|
+
const stored = loadStoredEmbeddings(options.embeddingsPath);
|
|
317
|
+
if (!stored) return { mode: "lexical", semantic: null, reason: "precomputed document embeddings are missing" };
|
|
318
|
+
const embedder = await loadEmbedder(options);
|
|
319
|
+
if (embedder.error) return { mode: "lexical", semantic: null, reason: `embedding model unavailable: ${embedder.error}` };
|
|
320
|
+
const vectors = new Map();
|
|
321
|
+
for (const document of index.documents) {
|
|
322
|
+
const entry = stored.get(document.record.repository_path);
|
|
323
|
+
// A document edited after the vectors were built ranks on BM25F alone.
|
|
324
|
+
if (entry && entry.textHash === documentTextHash(document)) vectors.set(document.id, entry.vector);
|
|
325
|
+
}
|
|
326
|
+
return { mode: "hybrid", semantic: { queryVector: await embedder.embed(`${EMBEDDING_MODEL.query_prefix}${text}`), vectors } };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function lexicalRanking(index, terms) {
|
|
298
330
|
const idf = inverseDocumentFrequency(index, terms);
|
|
299
331
|
const ranked = [];
|
|
300
332
|
for (const document of index.documents) {
|
|
@@ -302,23 +334,64 @@ export function rankSearchIndex(index, query, queryEn) {
|
|
|
302
334
|
if (score > 0) ranked.push({ document, score, matched });
|
|
303
335
|
}
|
|
304
336
|
ranked.sort((left, right) => right.score - left.score || compare(left.document.id, right.document.id));
|
|
305
|
-
return
|
|
337
|
+
return ranked;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function fuse(index, lexical, semantic) {
|
|
341
|
+
const { k, lexical_weight: lexicalWeight, semantic_weight: semanticWeight, depth } = SEARCH_FUSION;
|
|
342
|
+
const similarity = [];
|
|
343
|
+
for (const document of index.documents) {
|
|
344
|
+
const vector = semantic.vectors.get(document.id);
|
|
345
|
+
if (!vector) continue;
|
|
346
|
+
let dot = 0;
|
|
347
|
+
for (let position = 0; position < vector.length; position += 1) dot += vector[position] * semantic.queryVector[position];
|
|
348
|
+
similarity.push({ document, similarity: Math.round(dot * 1e6) / 1e6 });
|
|
349
|
+
}
|
|
350
|
+
similarity.sort((left, right) => right.similarity - left.similarity || compare(left.document.id, right.document.id));
|
|
351
|
+
const fused = new Map();
|
|
352
|
+
lexical.slice(0, depth).forEach(({ document, score, matched }, rank) => {
|
|
353
|
+
fused.set(document.id, { document, lexical_score: score, matched: new Set(matched), score: lexicalWeight / (k + rank + 1) });
|
|
354
|
+
});
|
|
355
|
+
similarity.slice(0, depth).forEach(({ document, similarity: value }, rank) => {
|
|
356
|
+
const entry = fused.get(document.id) ?? { document, matched: new Set(), score: 0 };
|
|
357
|
+
entry.matched.add("semantic");
|
|
358
|
+
entry.similarity = value;
|
|
359
|
+
entry.score += semanticWeight / (k + rank + 1);
|
|
360
|
+
fused.set(document.id, entry);
|
|
361
|
+
});
|
|
362
|
+
return [...fused.values()].map((entry) => ({ ...entry, score: Math.round(entry.score * 1e9) / 1e9 }))
|
|
363
|
+
.sort((left, right) => right.score - left.score || compare(left.document.id, right.document.id));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Rank documents for a query. Pure over an index and an optional semantic input; exported for evaluation. */
|
|
367
|
+
export function rankSearchIndex(index, query, queryEn, semantic = null) {
|
|
368
|
+
const terms = queryTerms(query, queryEn);
|
|
369
|
+
const lexical = lexicalRanking(index, terms);
|
|
370
|
+
return { terms, ranked: semantic ? fuse(index, lexical, semantic) : lexical };
|
|
306
371
|
}
|
|
307
372
|
|
|
308
373
|
function readCommand(document, section) {
|
|
309
374
|
return `stylegallery read ${document.id}${section && section.level > 1 ? ` --section ${section.slug}` : ""}`;
|
|
310
375
|
}
|
|
311
376
|
|
|
312
|
-
|
|
377
|
+
/**
|
|
378
|
+
* Search a built index. `prepared` is the result of prepareSemantic; without it
|
|
379
|
+
* the search is lexical. Synchronous and deterministic for a given input.
|
|
380
|
+
*/
|
|
381
|
+
export function searchIndex(index, input, prepared = null) {
|
|
313
382
|
const normalized = validateSearchInput(input);
|
|
314
|
-
const
|
|
383
|
+
const semantic = normalized.mode === "hybrid" ? prepared?.semantic ?? null : null;
|
|
384
|
+
const mode = semantic ? "hybrid" : "lexical";
|
|
385
|
+
const reason = normalized.mode === "hybrid" && !semantic ? prepared?.reason ?? "embedding model not loaded" : null;
|
|
386
|
+
const { terms, ranked } = rankSearchIndex(index, normalized.query, normalized.queryEn, semantic);
|
|
315
387
|
const selected = ranked.slice(0, normalized.limit);
|
|
316
388
|
const query = { text: normalized.query, ...(normalized.queryEn ? { query_en: normalized.queryEn } : {}) };
|
|
317
389
|
const page = { returned: selected.length, total: ranked.length, next_cursor: null };
|
|
390
|
+
const modeFields = { mode, ...(reason ? { mode_reason: reason } : {}) };
|
|
318
391
|
if (normalized.pathsOnly) {
|
|
319
|
-
return deepFreeze({ schema_version: SEARCH_SCHEMA_VERSION, query, snapshot: index.manifestVersionId, paths: selected.map(({ document }) => document.record.repository_path), page });
|
|
392
|
+
return deepFreeze({ schema_version: SEARCH_SCHEMA_VERSION, query, ...modeFields, snapshot: index.manifestVersionId, paths: selected.map(({ document }) => document.record.repository_path), page });
|
|
320
393
|
}
|
|
321
|
-
const results = selected.map(({ document, score, matched }) => {
|
|
394
|
+
const results = selected.map(({ document, score, matched, lexical_score: lexicalScore, similarity }) => {
|
|
322
395
|
const best = bestSnippet(document, terms);
|
|
323
396
|
return {
|
|
324
397
|
id: document.id,
|
|
@@ -329,11 +402,14 @@ export function searchIndex(index, input) {
|
|
|
329
402
|
matched_fields: [...matched].sort(),
|
|
330
403
|
snippets: best ? [best.snippet] : [],
|
|
331
404
|
next: { read: readCommand(document, best?.section) },
|
|
332
|
-
...(normalized.detailed ? { score, source_ref: document.record.stable_ref, version_id: document.record.version_id, material_ref: document.identity.stable_ref } : {}),
|
|
405
|
+
...(normalized.detailed ? { score, ...(semantic ? { lexical_score: lexicalScore ?? 0, ...(similarity !== undefined ? { similarity } : {}) } : {}), source_ref: document.record.stable_ref, version_id: document.record.version_id, material_ref: document.identity.stable_ref } : {}),
|
|
333
406
|
};
|
|
334
407
|
});
|
|
335
|
-
const envelope = { schema_version: SEARCH_SCHEMA_VERSION, query, snapshot: index.manifestVersionId, results, page, coverage: { truncated: false } };
|
|
336
|
-
if (normalized.detailed)
|
|
408
|
+
const envelope = { schema_version: SEARCH_SCHEMA_VERSION, query, ...modeFields, snapshot: index.manifestVersionId, results, page, coverage: { truncated: false } };
|
|
409
|
+
if (normalized.detailed) {
|
|
410
|
+
envelope.ranking = { method: "BM25F", k1: SEARCH_K1, fields: SEARCH_FIELDS, terms };
|
|
411
|
+
if (semantic) envelope.ranking.fusion = { ...SEARCH_FUSION, model: EMBEDDING_MODEL.source, revision: EMBEDDING_MODEL.revision };
|
|
412
|
+
}
|
|
337
413
|
while (Buffer.byteLength(JSON.stringify(envelope), "utf8") > SEARCH_BUDGET_BYTES && envelope.results.length > 1) {
|
|
338
414
|
envelope.results.pop();
|
|
339
415
|
envelope.page.returned = envelope.results.length;
|
|
@@ -394,13 +470,19 @@ export function loadSearchIndex(options) {
|
|
|
394
470
|
|
|
395
471
|
const PUBLIC_CODES = new Set(["material_input_invalid", "material_input_unknown", "material_query_invalid", "material_query_oversized",
|
|
396
472
|
"material_query_empty", "search_limit_invalid", "material_paths_only_invalid", "search_response_format_invalid", "read_reference_required",
|
|
397
|
-
"read_reference_not_found", "read_section_invalid", "read_outline_invalid", "read_mode_conflict", "read_section_not_found", "search_id_collision"
|
|
473
|
+
"read_reference_not_found", "read_section_invalid", "read_outline_invalid", "read_mode_conflict", "read_section_not_found", "search_id_collision",
|
|
474
|
+
"search_mode_invalid"]);
|
|
398
475
|
|
|
399
476
|
/** Run `search` or `read` and wrap it in the ok/failures envelope shared by CLI and MCP. */
|
|
400
|
-
export function invokeSearchOperation(operation, input, options) {
|
|
477
|
+
export async function invokeSearchOperation(operation, input, options) {
|
|
401
478
|
try {
|
|
402
479
|
const index = loadSearchIndex(options);
|
|
403
|
-
|
|
480
|
+
let result;
|
|
481
|
+
if (operation === "search") {
|
|
482
|
+
validateSearchInput(input);
|
|
483
|
+
const prepared = await prepareSemantic(index, input.query, input.query_en, input.mode ?? "hybrid", options?.semantic);
|
|
484
|
+
result = searchIndex(index, input, prepared);
|
|
485
|
+
} else result = readIndex(index, input);
|
|
404
486
|
return deepFreeze({ ok: true, operation, result });
|
|
405
487
|
} catch (error) {
|
|
406
488
|
const known = typeof error?.code === "string" && (PUBLIC_CODES.has(error.code) || error.code.startsWith("material_"));
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Semantic half of hybrid search: a small English embedding model run with
|
|
2
|
+
// onnxruntime-web (WebAssembly, an optional dependency). The model is never
|
|
3
|
+
// packaged: it is downloaded once into a cache directory and checked against
|
|
4
|
+
// pinned SHA-256 hashes. Document vectors are precomputed at build time into
|
|
5
|
+
// search-embeddings.json. Any failure leaves search on BM25F alone.
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
|
13
|
+
export const EMBEDDINGS_PATH = path.join(repositoryRoot, "consumer-reference/agent-native/v2/search-embeddings.json");
|
|
14
|
+
|
|
15
|
+
export const EMBEDDING_MODEL = Object.freeze({
|
|
16
|
+
id: "bge-small-en-v1.5-q8",
|
|
17
|
+
source: "Xenova/bge-small-en-v1.5",
|
|
18
|
+
revision: "ea104dacec62c0de699686887e3f920caeb4f3e3",
|
|
19
|
+
dims: 384,
|
|
20
|
+
pooling: "cls",
|
|
21
|
+
query_prefix: "Represent this sentence for searching relevant passages: ",
|
|
22
|
+
max_tokens: 512,
|
|
23
|
+
files: Object.freeze({
|
|
24
|
+
"model_quantized.onnx": Object.freeze({ remote: "onnx/model_quantized.onnx", sha256: "6c9c6101a956d62dfb5e7190c538226c0c5bb9cb27b651234b6df063ee7dbfe4", bytes: 34014426 }),
|
|
25
|
+
"tokenizer.json": Object.freeze({ remote: "tokenizer.json", sha256: "d241a60d5e8f04cc1b2b3e9ef7a4921b27bf526d9f6050ab90f9267a1f9e5c66", bytes: 711396 }),
|
|
26
|
+
}),
|
|
27
|
+
});
|
|
28
|
+
export const DOCUMENT_TEXT_CHARS = 1500;
|
|
29
|
+
const DOWNLOAD_TIMEOUT_MS = 120_000;
|
|
30
|
+
|
|
31
|
+
export function cacheDirectory(env = process.env) {
|
|
32
|
+
if (env.STYLEGALLERY_CACHE_DIR) return env.STYLEGALLERY_CACHE_DIR;
|
|
33
|
+
const base = env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
|
|
34
|
+
return path.join(base, "stylegallery");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function modelDirectory(env = process.env) {
|
|
38
|
+
return path.join(cacheDirectory(env), "models", EMBEDDING_MODEL.id);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const sha256 = (buffer) => crypto.createHash("sha256").update(buffer).digest("hex");
|
|
42
|
+
|
|
43
|
+
/** The text a document is embedded from: title, description, headings, then prose without code. */
|
|
44
|
+
export function documentEmbeddingText(document) {
|
|
45
|
+
const body = document.text.replace(/^---\n[\s\S]*?\n---\n/, "").replace(/```[\s\S]*?```/g, " ")
|
|
46
|
+
.replace(/[#*`|>-]+/g, " ").replace(/\s+/g, " ").trim();
|
|
47
|
+
return [document.title, document.description, document.sections.map((section) => section.title).join("; "), body]
|
|
48
|
+
.filter(Boolean).join(". ").slice(0, DOCUMENT_TEXT_CHARS);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function documentTextHash(document) {
|
|
52
|
+
return sha256(Buffer.from(documentEmbeddingText(document), "utf8"));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---- BERT uncased WordPiece tokenizer (matches the model's tokenizer.json) ----
|
|
56
|
+
|
|
57
|
+
function isPunctuation(code, char) {
|
|
58
|
+
return (code >= 33 && code <= 47) || (code >= 58 && code <= 64) || (code >= 91 && code <= 96) || (code >= 123 && code <= 126) || /\p{P}/u.test(char);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isCjk(code) {
|
|
62
|
+
return (code >= 0x4e00 && code <= 0x9fff) || (code >= 0x3400 && code <= 0x4dbf) || (code >= 0x20000 && code <= 0x2a6df)
|
|
63
|
+
|| (code >= 0x2a700 && code <= 0x2b73f) || (code >= 0x2b740 && code <= 0x2b81f) || (code >= 0x2b820 && code <= 0x2ceaf)
|
|
64
|
+
|| (code >= 0xf900 && code <= 0xfaff) || (code >= 0x2f800 && code <= 0x2fa1f);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function basicTokens(text) {
|
|
68
|
+
let cleaned = "";
|
|
69
|
+
for (const char of text) {
|
|
70
|
+
const code = char.codePointAt(0);
|
|
71
|
+
if (code === 0 || code === 0xfffd || (/\p{Cc}|\p{Cf}/u.test(char) && !/[\t\n\r]/.test(char))) continue;
|
|
72
|
+
if (/\s/u.test(char)) cleaned += " ";
|
|
73
|
+
else if (isCjk(code)) cleaned += ` ${char} `;
|
|
74
|
+
else cleaned += char;
|
|
75
|
+
}
|
|
76
|
+
cleaned = cleaned.toLowerCase().normalize("NFD").replace(/\p{Mn}/gu, "");
|
|
77
|
+
const tokens = [];
|
|
78
|
+
for (const word of cleaned.split(" ")) {
|
|
79
|
+
let current = "";
|
|
80
|
+
for (const char of word) {
|
|
81
|
+
if (isPunctuation(char.codePointAt(0), char)) {
|
|
82
|
+
if (current) tokens.push(current);
|
|
83
|
+
tokens.push(char);
|
|
84
|
+
current = "";
|
|
85
|
+
} else current += char;
|
|
86
|
+
}
|
|
87
|
+
if (current) tokens.push(current);
|
|
88
|
+
}
|
|
89
|
+
return tokens;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function createTokenizer(tokenizerJson) {
|
|
93
|
+
const vocab = new Map(Object.entries(tokenizerJson.model.vocab));
|
|
94
|
+
const unk = vocab.get("[UNK]");
|
|
95
|
+
const cls = vocab.get("[CLS]");
|
|
96
|
+
const sep = vocab.get("[SEP]");
|
|
97
|
+
const wordPiece = (word) => {
|
|
98
|
+
const chars = [...word];
|
|
99
|
+
if (chars.length > 100) return [unk];
|
|
100
|
+
const pieces = [];
|
|
101
|
+
let start = 0;
|
|
102
|
+
while (start < chars.length) {
|
|
103
|
+
let end = chars.length;
|
|
104
|
+
let found;
|
|
105
|
+
while (start < end) {
|
|
106
|
+
const piece = `${start > 0 ? "##" : ""}${chars.slice(start, end).join("")}`;
|
|
107
|
+
if (vocab.has(piece)) { found = vocab.get(piece); break; }
|
|
108
|
+
end -= 1;
|
|
109
|
+
}
|
|
110
|
+
if (found === undefined) return [unk];
|
|
111
|
+
pieces.push(found);
|
|
112
|
+
start = end;
|
|
113
|
+
}
|
|
114
|
+
return pieces;
|
|
115
|
+
};
|
|
116
|
+
return (text) => {
|
|
117
|
+
const ids = basicTokens(text).flatMap(wordPiece).slice(0, EMBEDDING_MODEL.max_tokens - 2);
|
|
118
|
+
return [cls, ...ids, sep];
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---- model download and loading ----
|
|
123
|
+
|
|
124
|
+
async function download(url, target, expected, log) {
|
|
125
|
+
log(`stylegallery: downloading ${path.basename(target)} (${Math.round(expected.bytes / 1e6)} MB) for hybrid search into ${path.dirname(target)}`);
|
|
126
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), redirect: "follow" });
|
|
127
|
+
if (!response.ok) throw new Error(`download failed: HTTP ${response.status}`);
|
|
128
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
129
|
+
if (sha256(buffer) !== expected.sha256) throw new Error(`download of ${path.basename(target)} failed its SHA-256 check`);
|
|
130
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
131
|
+
fs.writeFileSync(temporary, buffer);
|
|
132
|
+
fs.renameSync(temporary, target);
|
|
133
|
+
return buffer;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function modelFile(name, options) {
|
|
137
|
+
const expected = EMBEDDING_MODEL.files[name];
|
|
138
|
+
const directory = modelDirectory(options.env);
|
|
139
|
+
const target = path.join(directory, name);
|
|
140
|
+
if (fs.existsSync(target)) {
|
|
141
|
+
const buffer = fs.readFileSync(target);
|
|
142
|
+
if (sha256(buffer) === expected.sha256) return buffer;
|
|
143
|
+
fs.rmSync(target, { force: true });
|
|
144
|
+
}
|
|
145
|
+
if (options.env.STYLEGALLERY_OFFLINE === "1") throw new Error("model not cached and STYLEGALLERY_OFFLINE=1");
|
|
146
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
147
|
+
const url = `https://huggingface.co/${EMBEDDING_MODEL.source}/resolve/${EMBEDDING_MODEL.revision}/${expected.remote}`;
|
|
148
|
+
return download(url, target, expected, options.log);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let embedderPromise = null;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Load the query embedder once per process. Resolves to { embed } or to
|
|
155
|
+
* { error } with a one-line reason; never rejects.
|
|
156
|
+
*/
|
|
157
|
+
export function loadEmbedder(options = {}) {
|
|
158
|
+
const resolved = { env: options.env ?? process.env, log: options.log ?? ((line) => process.stderr.write(`${line}\n`)) };
|
|
159
|
+
embedderPromise ??= (async () => {
|
|
160
|
+
let ort;
|
|
161
|
+
try { ort = await import("onnxruntime-web"); } catch { throw new Error("optional dependency onnxruntime-web is not installed"); }
|
|
162
|
+
const tokenizerBuffer = await modelFile("tokenizer.json", resolved);
|
|
163
|
+
const modelBuffer = await modelFile("model_quantized.onnx", resolved);
|
|
164
|
+
ort.env.logLevel = "error";
|
|
165
|
+
ort.env.wasm.numThreads = 1;
|
|
166
|
+
const session = await ort.InferenceSession.create(modelBuffer);
|
|
167
|
+
const tokenize = createTokenizer(JSON.parse(tokenizerBuffer.toString("utf8")));
|
|
168
|
+
const embed = async (text) => {
|
|
169
|
+
const ids = tokenize(text);
|
|
170
|
+
const shape = [1, ids.length];
|
|
171
|
+
const output = await session.run({
|
|
172
|
+
input_ids: new ort.Tensor("int64", BigInt64Array.from(ids, BigInt), shape),
|
|
173
|
+
attention_mask: new ort.Tensor("int64", new BigInt64Array(ids.length).fill(1n), shape),
|
|
174
|
+
token_type_ids: new ort.Tensor("int64", new BigInt64Array(ids.length), shape),
|
|
175
|
+
});
|
|
176
|
+
const vector = Array.from(output.last_hidden_state.data.subarray(0, EMBEDDING_MODEL.dims));
|
|
177
|
+
const norm = Math.hypot(...vector) || 1;
|
|
178
|
+
return vector.map((value) => value / norm);
|
|
179
|
+
};
|
|
180
|
+
return { embed };
|
|
181
|
+
})().catch((error) => ({ error: error.message }));
|
|
182
|
+
return embedderPromise;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---- stored document vectors ----
|
|
186
|
+
|
|
187
|
+
/** int8 quantization keeps the file small and makes stored vectors byte-stable across platforms. */
|
|
188
|
+
export function quantize(vector) {
|
|
189
|
+
return Buffer.from(Int8Array.from(vector, (value) => Math.max(-127, Math.min(127, Math.round(value * 127)))).buffer).toString("base64");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function dequantize(encoded) {
|
|
193
|
+
const bytes = Buffer.from(encoded, "base64");
|
|
194
|
+
return Float32Array.from(new Int8Array(bytes.buffer, bytes.byteOffset, bytes.length), (value) => value / 127);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let storedCache = null;
|
|
198
|
+
|
|
199
|
+
/** Stored document vectors keyed by repository path, or null when the file is missing or for another model. */
|
|
200
|
+
export function loadStoredEmbeddings(file = EMBEDDINGS_PATH) {
|
|
201
|
+
if (storedCache?.file === file) return storedCache.value;
|
|
202
|
+
let value = null;
|
|
203
|
+
try {
|
|
204
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
205
|
+
if (parsed.model?.id === EMBEDDING_MODEL.id && parsed.model?.revision === EMBEDDING_MODEL.revision) {
|
|
206
|
+
value = new Map(Object.entries(parsed.documents).map(([key, entry]) => [key, { textHash: entry.text_sha256, vector: dequantize(entry.vector) }]));
|
|
207
|
+
}
|
|
208
|
+
} catch { value = null; }
|
|
209
|
+
storedCache = { file, value };
|
|
210
|
+
return value;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function embeddingsFileContent(entries) {
|
|
214
|
+
const documents = Object.fromEntries([...entries].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)));
|
|
215
|
+
const { files, ...model } = EMBEDDING_MODEL;
|
|
216
|
+
return `${JSON.stringify({
|
|
217
|
+
schema_version: "1.0",
|
|
218
|
+
description: "Precomputed document vectors for hybrid search. Regenerate with node scripts/agent-native/v2/generate-search-embeddings.mjs.",
|
|
219
|
+
model: { ...model, files: Object.fromEntries(Object.entries(files).map(([name, { sha256: hash }]) => [name, hash])) },
|
|
220
|
+
document_text_chars: DOCUMENT_TEXT_CHARS,
|
|
221
|
+
quantization: "int8, value = byte / 127",
|
|
222
|
+
documents,
|
|
223
|
+
}, null, 1)}\n`;
|
|
224
|
+
}
|
package/scripts/compiler/mcp.mjs
CHANGED
|
@@ -13,7 +13,7 @@ const readOnly = { readOnlyHint: true, destructiveHint: false, idempotentHint: t
|
|
|
13
13
|
export const searchToolDefinitions = Object.freeze([
|
|
14
14
|
{
|
|
15
15
|
name: "search",
|
|
16
|
-
description: "Search the StyleGallery Markdown corpus (layout patterns, recipes, motion, design engineering, platform guides, terminology, expression).
|
|
16
|
+
description: "Search the StyleGallery Markdown corpus (layout patterns, recipes, motion, design engineering, platform guides, terminology, expression). Hybrid ranking by default: BM25F fused with an English embedding model (downloaded once, then offline); falls back to BM25F alone when the model is unavailable, and the result's mode field says which ran. Returns light results: id, path, title, description, a verbatim snippet, and the next read command. When the question is not in English, also pass query_en with an English rewrite; Korean-only queries match only documents with Korean aliases.",
|
|
17
17
|
inputSchema: {
|
|
18
18
|
type: "object", additionalProperties: false, required: ["query"],
|
|
19
19
|
properties: {
|
|
@@ -21,6 +21,7 @@ export const searchToolDefinitions = Object.freeze([
|
|
|
21
21
|
query_en: { type: "string", description: "English rewrite of query. Strongly recommended when query is not English." },
|
|
22
22
|
limit: { type: "integer", minimum: 1, maximum: 50, default: 5 },
|
|
23
23
|
paths_only: { type: "boolean", description: "Return repository-relative paths only." },
|
|
24
|
+
mode: { type: "string", enum: ["hybrid", "lexical"], default: "hybrid", description: "hybrid fuses BM25F with embedding similarity; lexical is BM25F only." },
|
|
24
25
|
response_format: { type: "string", enum: ["concise", "detailed"], default: "concise", description: "detailed adds scores, hash identities, and ranking terms." },
|
|
25
26
|
},
|
|
26
27
|
},
|
|
@@ -72,7 +73,7 @@ export function createSgMcpServer(config = {}) {
|
|
|
72
73
|
let input = request.params.arguments ?? {};
|
|
73
74
|
let report;
|
|
74
75
|
if (operation === "search" || operation === "read") {
|
|
75
|
-
report = invokeSearchOperation(operation, input, materialRuntimeOptions());
|
|
76
|
+
report = await invokeSearchOperation(operation, input, materialRuntimeOptions());
|
|
76
77
|
} else if (Object.hasOwn(compilerOperations, operation) || operation === "workflow") {
|
|
77
78
|
const signals = [lifetime.signal, extra.signal, config.signal].filter(Boolean);
|
|
78
79
|
report = await executeCompiler(operation, input, { ...config, signal: AbortSignal.any(signals) });
|
|
@@ -22,6 +22,9 @@ Find and read documents (offline, deterministic):
|
|
|
22
22
|
--limit <n> Results to return, 1-50 (default 5).
|
|
23
23
|
--paths-only Print repository-relative paths only.
|
|
24
24
|
--detailed Add scores, hash identities, and ranking terms.
|
|
25
|
+
--mode <hybrid|lexical> hybrid (default) fuses BM25F with an English embedding model downloaded
|
|
26
|
+
once to ~/.cache/stylegallery; lexical is BM25F only. Falls back to
|
|
27
|
+
lexical with a warning when the model cannot load.
|
|
25
28
|
read <id|path> Read one document by id (sg:layout/sticky-header), path, or StableRef.
|
|
26
29
|
--outline List headings with section slugs instead of the content.
|
|
27
30
|
--section <slug> Read one section only.
|
|
@@ -38,8 +41,8 @@ Governed records:
|
|
|
38
41
|
registrar | conservator Check claim records | measure Layout patterns in Chromium.
|
|
39
42
|
|
|
40
43
|
Connect your agents:
|
|
41
|
-
setup Install the search skill and register stylegallery-mcp for Claude Code and
|
|
42
|
-
--agent <claude|codex|all> Agents to configure (default: the ones detected on this machine).
|
|
44
|
+
setup Install the search skill and register stylegallery-mcp for Claude Code, Codex, and OmO.
|
|
45
|
+
--agent <claude|codex|omo|all> Agents to configure (default: the ones detected on this machine).
|
|
43
46
|
--only <skill|mcp> Configure only the skill or only the MCP server.
|
|
44
47
|
--dry-run Print the planned changes without writing anything.
|
|
45
48
|
--uninstall Remove what setup installed.
|
|
@@ -83,7 +86,7 @@ function parseOptions(command, args, specs) {
|
|
|
83
86
|
async function runSearch(args) {
|
|
84
87
|
const parsed = parseOptions("search", args, {
|
|
85
88
|
"--query": { field: "query" }, "--query-en": { field: "query_en" }, "--limit": { field: "limit", integer: true },
|
|
86
|
-
"--paths-only": { field: "paths_only", flag: true }, "--detailed": { field: "detailed", flag: true },
|
|
89
|
+
"--paths-only": { field: "paths_only", flag: true }, "--detailed": { field: "detailed", flag: true }, "--mode": { field: "mode" },
|
|
87
90
|
});
|
|
88
91
|
if (parsed.error) return parsed.error;
|
|
89
92
|
const { values, positional } = parsed;
|
|
@@ -94,7 +97,9 @@ async function runSearch(args) {
|
|
|
94
97
|
const input = { ...rest, query, ...(detailed ? { response_format: "detailed" } : {}) };
|
|
95
98
|
const { invokeSearchOperation } = await import("./agent-native/v2/material-search-v3.mjs");
|
|
96
99
|
const { materialRuntimeOptions } = await import("./agent-native/v2/material-cli-adapter.mjs");
|
|
97
|
-
|
|
100
|
+
const outcome = await invokeSearchOperation("search", input, materialRuntimeOptions());
|
|
101
|
+
if (outcome.ok && outcome.result.mode_reason) process.stderr.write(`stylegallery: hybrid search unavailable (${outcome.result.mode_reason}); using lexical BM25F ranking\n`);
|
|
102
|
+
return report(outcome);
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
async function runRead(args) {
|
|
@@ -103,7 +108,7 @@ async function runRead(args) {
|
|
|
103
108
|
if (parsed.positional.length !== 1) return failure("read", "argument_value_required", "give exactly one id, path, or StableRef: stylegallery read sg:layout/sticky-header");
|
|
104
109
|
const { invokeSearchOperation } = await import("./agent-native/v2/material-search-v3.mjs");
|
|
105
110
|
const { materialRuntimeOptions } = await import("./agent-native/v2/material-cli-adapter.mjs");
|
|
106
|
-
return report(invokeSearchOperation("read", { reference: parsed.positional[0], ...parsed.values }, materialRuntimeOptions()));
|
|
111
|
+
return report(await invokeSearchOperation("read", { reference: parsed.positional[0], ...parsed.values }, materialRuntimeOptions()));
|
|
107
112
|
}
|
|
108
113
|
|
|
109
114
|
async function runRetrieve(args) {
|