From 2afea115e8b8500f669e647e190769eec4c430fc Mon Sep 17 00:00:00 2001 From: Aldrich_CC <109075336+Chen17-sq@users.noreply.github.com> Date: Sat, 16 May 2026 04:41:59 +0800 Subject: [PATCH] feat(contexto): tighten public types + honor retrieval config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related changes that address both bullets of #133: # 1. Type the mindmap item shape (drop ``any[]``) ``SearchResult.items`` was ``any[]``. Anyone implementing ``ContextoBackend`` (#116) had to read ``helpers.ts`` to learn the field names. Replaced with two new public types that mirror ``@ekai/mindmap``'s ``ConversationItem`` / ``ScoredItem``: * ``MindmapItem`` — id, role, content, timestamp, metadata * ``ScoredMindmapItem`` — { item: MindmapItem, score, estimatedTokens } Both are re-exported from ``packages/contexto/src/index.ts``. No runtime import is added (the types live in the contexto package; the mindmap shape they mirror is a contract, not an import). The strict typing surfaced a latent bug in ``engine/base.ts``: the dedup loop used ``(r.item ?? r).id``, but neither backend (local + remote) returns raw items — both wrap in ``{item, score, ...}`` and ``MindmapItem.id`` is required. The ``?? r`` fallback was therefore unreachable, and on the off chance it did execute, ``r.id`` would have been ``undefined`` (silently disabling dedup). Replaced both call sites with ``r.item?.id`` and left a comment explaining why. # 2. Honor ``maxResults`` (was hardcoded) + document the defaults ``engine/base.ts`` was using ``DEFAULT_MAX_RESULTS = 7`` regardless of what callers passed via ``BaseConfig``. ``minScore`` was already wired through (line 89). Added ``maxResults?: number`` to BaseConfig and threaded it through the search call. ``filter`` was also already wired through but undocumented. Configuration is now documented in three places, all consistent: * ``packages/contexto/openclaw.plugin.json`` — the runtime schema OpenClaw uses to validate plugin config; added entries for ``maxResults`` / ``minScore`` / ``filter``. * ``packages/contexto/src/index.ts`` — the inline ``configSchema`` on the default-exported plugin definition; same three additions, plus the ``register()`` function now reads them from ``api.pluginConfig`` and threads them into ``base``. * ``packages/contexto/README.md`` Configuration table — gains rows for ``contextEnabled``, ``maxContextChars``, ``maxResults``, ``minScore``, ``filter`` with defaults called out inline. * ``docs/contexto.md`` — new "Tuning Retrieval" section that summarises the three knobs and points back to the README. Closes #133. Test plan --------- * ``pnpm --filter @ekai/contexto run build`` (tsc --noEmit) passes cleanly — the strict typing change fixed a latent bug in ``engine/base.ts`` that the loose ``any[]`` typing had been hiding. * No runtime imports added; the new types reference an existing shape from ``@ekai/mindmap`` without depending on it at runtime. * The ``register()`` reader in ``index.ts`` falls back to ``undefined`` for missing fields, which then falls through to the engine's ``DEFAULT_MAX_RESULTS`` / ``DEFAULT_MIN_SCORE`` — existing users who don't set the new fields see zero behaviour change. --- docs/contexto.md | 12 ++++++++++ packages/contexto/README.md | 5 ++++ packages/contexto/openclaw.plugin.json | 14 +++++++++++ packages/contexto/src/engine/base.ts | 18 +++++++++----- packages/contexto/src/index.ts | 15 +++++++++++- packages/contexto/src/types.ts | 33 +++++++++++++++++++++++++- 6 files changed, 89 insertions(+), 8 deletions(-) diff --git a/docs/contexto.md b/docs/contexto.md index f94be56..83a22bd 100644 --- a/docs/contexto.md +++ b/docs/contexto.md @@ -29,3 +29,15 @@ To ensure the agent always has access to your latest thoughts, Contexto implemen - **Live Tracking:** It listens to the configured `knowledgeFolder` (your Obsidian vault). - **Auto-Embedding:** Any changes, additions, or modifications to your markdown files trigger a debounced update. The system automatically recompiles and embeds the new information into the QMD index, keeping the knowledge base perpetually fresh. + +## 4. Tuning Retrieval + +Three retrieval knobs are exposed through the plugin config — defaults match what's hardwired into `engine/base.ts` and what `Mindmap.search` uses internally: + +| Setting | Default | Effect | +| --- | --- | --- | +| `maxResults` | `7` | Upper bound on items returned per `assemble()` call before dedup against the already-injected set. | +| `minScore` | `0.45` | Similarity floor. Items scoring below this are dropped before formatting. | +| `filter` | `{}` | Metadata-equality filter merged into the backend search. The engine pins `source: 'summary'`; anything you set here is spread on top, so passing `{ source: 'episode' }` switches the kind of items returned. | + +See the package [README's Configuration table](../packages/contexto/README.md#configuration) for the full property list. diff --git a/packages/contexto/README.md b/packages/contexto/README.md index 483e7c8..fd40f00 100644 --- a/packages/contexto/README.md +++ b/packages/contexto/README.md @@ -122,6 +122,11 @@ For the deeper technical reasoning: | --- | --- | --- | --- | | `apiKey` | string | Yes (remote) | Your Contexto API key | | `mode` | string | No | `remote` (default) or `local` | +| `contextEnabled` | boolean | No | Set to `false` to disable context retrieval and injection (default: `true`) | +| `maxContextChars` | number | No | Cap on the assembled context block, in characters (default: `2000`) | +| `maxResults` | number | No | Max number of memory items retrieved per `assemble()` call (default: `7`) | +| `minScore` | number | No | Minimum similarity score a candidate item must exceed (default: `0.45`) | +| `filter` | object | No | Metadata-equality filter merged into backend search; engine pins `source: 'summary'` and your filter is spread on top | ### Remote mode (default) diff --git a/packages/contexto/openclaw.plugin.json b/packages/contexto/openclaw.plugin.json index 0888222..87da2f0 100644 --- a/packages/contexto/openclaw.plugin.json +++ b/packages/contexto/openclaw.plugin.json @@ -21,6 +21,20 @@ "type": "number", "description": "Maximum characters of context to inject (default: 2000)" }, + "maxResults": { + "type": "number", + "default": 7, + "description": "Maximum number of memory items to retrieve per assemble() call (default: 7)" + }, + "minScore": { + "type": "number", + "default": 0.45, + "description": "Minimum similarity score for a memory item to be considered relevant (default: 0.45)" + }, + "filter": { + "type": "object", + "description": "Optional metadata-equality filter merged into the backend search. The engine pins source='summary'; anything set here is spread on top." + }, "mode": { "type": "string", "default": "remote", diff --git a/packages/contexto/src/engine/base.ts b/packages/contexto/src/engine/base.ts index f07aea8..cad709d 100644 --- a/packages/contexto/src/engine/base.ts +++ b/packages/contexto/src/engine/base.ts @@ -87,18 +87,24 @@ export abstract class AbstractContextEngine implements ContextEngine { this.logger.info(`[contexto] Fetching context for query: "${query.slice(0, 100)}"`); const filter = { source: 'summary', ...this.config.filter }; const minScore = this.config.minScore ?? DEFAULT_MIN_SCORE; - const result = await this.backend.search(query, DEFAULT_MAX_RESULTS, filter, minScore); + const maxResults = this.config.maxResults ?? DEFAULT_MAX_RESULTS; + const result = await this.backend.search(query, maxResults, filter, minScore); if (!result?.items?.length) { return { messages, estimatedTokens: 0 }; } - const itemSummary = result.items.map((r: any, i: number) => `[${i}] ${r.item?.content?.length ?? 0} chars`).join(', '); + const itemSummary = result.items.map((r, i) => `[${i}] ${r.item?.content?.length ?? 0} chars`).join(', '); this.logger.info(`[contexto] Mindmap returned ${result.items.length} items (${itemSummary}), paths: ${JSON.stringify(result.paths)}`); - // Deduplicate: filter out items already injected in this session - const filtered = result.items.filter((r: any) => { - const id = (r.item ?? r).id; + // Deduplicate: filter out items already injected in this session. + // The dedup key is the inner item's id; older defensive code used + // ``(r.item ?? r).id`` to also support a raw-item shape, but both + // backends (local + remote) wrap items in ``{item, score, ...}`` + // and ``MindmapItem.id`` is required — so the inner access is + // always defined. + const filtered = result.items.filter((r) => { + const id = r.item?.id; return !id || !this.state.injectedItemIds.has(id); }); @@ -112,7 +118,7 @@ export abstract class AbstractContextEngine implements ContextEngine { // Record injected item IDs for (const r of filtered) { - const id = (r.item ?? r).id; + const id = r.item?.id; if (id) this.state.injectedItemIds.add(id); } diff --git a/packages/contexto/src/index.ts b/packages/contexto/src/index.ts index 430af4b..e6f77db 100644 --- a/packages/contexto/src/index.ts +++ b/packages/contexto/src/index.ts @@ -4,7 +4,14 @@ import { LocalBackend } from './local/index.js'; import type { ResolvedCredentials } from './local/index.js'; import { createContextEngine } from './engine/index.js'; -export type { ContextoBackend, SearchResult, WebhookPayload, Logger } from './types.js'; +export type { + ContextoBackend, + MindmapItem, + ScoredMindmapItem, + SearchResult, + WebhookPayload, + Logger, +} from './types.js'; export { RemoteBackend } from './client.js'; export { LocalBackend } from './local/index.js'; export type { LocalBackendConfig, ResolvedCredentials, EpisodeSummary } from './local/index.js'; @@ -21,6 +28,9 @@ export default { apiKey: { type: 'string' }, contextEnabled: { type: 'boolean', default: true }, maxContextChars: { type: 'number' }, + maxResults: { type: 'number', default: 7 }, + minScore: { type: 'number', default: 0.45 }, + filter: { type: 'object' }, compactThreshold: { type: 'number', default: 0.50 }, compactionStrategy: { type: 'string', default: 'default' }, mode: { type: 'string', default: 'remote' }, @@ -36,6 +46,9 @@ export default { apiKey: api.pluginConfig?.apiKey, contextEnabled: api.pluginConfig?.contextEnabled ?? true, maxContextChars: api.pluginConfig?.maxContextChars, + maxResults: api.pluginConfig?.maxResults, + minScore: api.pluginConfig?.minScore, + filter: api.pluginConfig?.filter, mode: backendMode as 'remote' | 'local', }; diff --git a/packages/contexto/src/types.ts b/packages/contexto/src/types.ts index 8965d0a..e378813 100644 --- a/packages/contexto/src/types.ts +++ b/packages/contexto/src/types.ts @@ -3,7 +3,16 @@ export interface BaseConfig { contextEnabled?: boolean; maxContextChars?: number; + /** Max number of memory items to retrieve per assemble() call. Default: 7. */ + maxResults?: number; + /** Minimum similarity score for an item to be considered relevant. Default: 0.45. */ minScore?: number; + /** + * Optional metadata-equality filter merged into the backend search. + * The engine always pins ``source: 'summary'`` — anything you set here is + * spread on top, so passing ``{ source: 'episode' }`` switches the kind + * of items returned. Pass ``{}`` to keep the default. + */ filter?: Record; mode?: 'remote' | 'local'; } @@ -31,9 +40,31 @@ export interface WebhookPayload { data?: Record; } +/** + * One memory item carried in a mindmap search result. Matches + * ``@ekai/mindmap``'s ``ConversationItem`` shape (the storage record), + * minus the embedding vector which the search layer never echoes back + * to callers. Re-exposed here so backend implementers (#116) don't + * have to import from a different package to learn the field names. + */ +export interface MindmapItem { + id: string; + role: string; + content: string; + timestamp?: string; + metadata?: Record; +} + +/** A search hit: the item + its similarity score + an estimated token cost. */ +export interface ScoredMindmapItem { + item: MindmapItem; + score: number; + estimatedTokens: number; +} + /** Shape returned by the mindmap search endpoint (ScoredQueryResult). */ export interface SearchResult { - items: any[]; + items: ScoredMindmapItem[]; paths?: string[][]; }