@@ -30,11 +30,23 @@ import {
3030} from "./services/timeline.ts" ;
3131import type { KnowledgeConfig } from "./mount-config.ts" ;
3232import type { GrantConfig } from "./routes/deps.ts" ;
33+ import type {
34+ DocumentStore ,
35+ MemoryProvider ,
36+ SourceProvider ,
37+ } from "./ports/types.ts" ;
3338
3439// Re-export so hosts typing plane results don't reach into services/.
3540export type { HybridSearchResult } from "./services/search.ts" ;
3641export type { SearchHit } from "./core/schemas/search.ts" ;
3742export type { VisibilitySpec } from "./core/schemas/document.ts" ;
43+ export type {
44+ DocumentStore ,
45+ DocumentStoreAddParams ,
46+ LiveSearchItem ,
47+ MemoryProvider ,
48+ SourceProvider ,
49+ } from "./ports/types.ts" ;
3850
3951export type ChatMessage = {
4052 role : "system" | "user" | "assistant" ;
@@ -291,6 +303,20 @@ export type KnowledgePlaneOptions = {
291303 generate ?: Generate ;
292304 /** Required for `add({ file })`; omit if the host only adds text content. */
293305 textExtractor ?: TextExtractor ;
306+ /**
307+ * Override durable storage. When set, the plane does not open Postgres or
308+ * call embed/rerank endpoints — useful for fakes and alternate backends.
309+ */
310+ documentStore ?: DocumentStore ;
311+ /**
312+ * Live source connectors. Wired into find/ask merge in CL-5227; accepted
313+ * here so mounts can declare them early.
314+ */
315+ sources ?: SourceProvider [ ] ;
316+ /**
317+ * Memory port type accepted for mount wiring; remember/recall product is M3.
318+ */
319+ memory ?: MemoryProvider ;
294320} ;
295321
296322function resolveFindLimit ( limit : number | undefined ) : number {
@@ -407,12 +433,180 @@ function resolveShareAndVisibility(params: KnowledgeAddParams): {
407433 *
408434 * - `grants` is required for `ask()` (in-process capability check). Standalone
409435 * add/find callers may omit it — same as #8's out-of-band plane.
410- * - Rerank config is validated at construction (same as mount).
436+ * - Rerank config is validated at construction (same as mount) when using the
437+ * default Postgres-backed store.
438+ * - Pass `options.documentStore` to skip Postgres entirely (fakes / overrides).
439+ * When a store is provided, `config` may be omitted.
411440 */
412441export function createKnowledgePlane (
413- config : KnowledgeConfig ,
442+ config : KnowledgeConfig | undefined ,
414443 grants ?: GrantConfig ,
415444 options : KnowledgePlaneOptions = { } ,
445+ ) : KnowledgePlane {
446+ if ( options . documentStore ) {
447+ return createPlaneFromStore ( options . documentStore , grants , options ) ;
448+ }
449+ if ( ! config ) {
450+ throw new KnowledgeError (
451+ 500 ,
452+ "KnowledgeConfig is required when documentStore is not provided" ,
453+ ) ;
454+ }
455+ return createPlaneFromEngine ( config , grants , options ) ;
456+ }
457+
458+ /** Plane backed by an injected DocumentStore (fake or host override). */
459+ function createPlaneFromStore (
460+ store : DocumentStore ,
461+ grants : GrantConfig | undefined ,
462+ options : KnowledgePlaneOptions ,
463+ ) : KnowledgePlane {
464+ // sources/memory held for mount completeness; merge/memory product later.
465+ void options . sources ;
466+ void options . memory ;
467+
468+ const plane : KnowledgePlane = {
469+ async find ( params ) {
470+ const limit = resolveFindLimit ( params . limit ) ;
471+ return store . find ( {
472+ tenantId : params . tenantId ,
473+ principalId : params . principalId ,
474+ query : params . query ,
475+ limit,
476+ ...( params . includeEvidence !== undefined
477+ ? { includeEvidence : params . includeEvidence }
478+ : { } ) ,
479+ } ) ;
480+ } ,
481+
482+ async ask ( params ) {
483+ if ( ! grants ) {
484+ throw new KnowledgeError (
485+ 501 ,
486+ "ask() requires a GrantConfig. Pass grants to " +
487+ "createKnowledgePlane/mountKnowledgeEngine." ,
488+ ) ;
489+ }
490+ const decision = await authorize (
491+ grants . grantStore ,
492+ params . principalId ,
493+ params . tenantId ,
494+ "knowledge" ,
495+ "find" ,
496+ grants . conditionRegistry ,
497+ ) ;
498+ if ( decision . effect !== "allow" ) {
499+ const effect = decision . effect ?? "no-matching-grant" ;
500+ log . info (
501+ `ask: denied knowledge:find for ${ params . principalId } (effect=${ effect } )` ,
502+ {
503+ principalId : params . principalId ,
504+ effect,
505+ } ,
506+ ) ;
507+ throw new KnowledgeNotPermittedError ( ) ;
508+ }
509+ if ( ! options . generate ) {
510+ throw new KnowledgeError (
511+ 501 ,
512+ "ask() requires a `generate` function. Pass one to " +
513+ "createKnowledgePlane/mountKnowledgeEngine, wired to your " +
514+ "inference layer." ,
515+ ) ;
516+ }
517+ const findResult = await plane . find ( {
518+ tenantId : params . tenantId ,
519+ principalId : params . principalId ,
520+ query : params . query ,
521+ includeEvidence : true ,
522+ ...( params . limit !== undefined ? { limit : params . limit } : { } ) ,
523+ } ) ;
524+ return synthesizeAnswer (
525+ params . query ,
526+ {
527+ hits : findItemsToHits ( findResult . items ) ,
528+ evidence : findResult . evidence ?? "none" ,
529+ } ,
530+ options . generate ,
531+ ) ;
532+ } ,
533+
534+ async add ( params ) {
535+ const hasContent = params . content !== undefined ;
536+ const hasFile = params . file !== undefined ;
537+ if ( hasContent === hasFile ) {
538+ throw new KnowledgeError (
539+ 400 ,
540+ "provide exactly one of content or file" ,
541+ ) ;
542+ }
543+
544+ let title : string ;
545+ let text : string ;
546+ if ( params . content ) {
547+ title = params . content . title ;
548+ text = params . content . text ;
549+ } else {
550+ const file = params . file ! ;
551+ if ( ! options . textExtractor ) {
552+ throw new KnowledgeError (
553+ 400 ,
554+ "file requires a textExtractor on the knowledge plane" ,
555+ ) ;
556+ }
557+ const extracted = await options . textExtractor . extract ( {
558+ bytes : file . bytes ,
559+ ...( file . mimeType !== undefined ? { mimeType : file . mimeType } : { } ) ,
560+ ...( file . filename !== undefined ? { filename : file . filename } : { } ) ,
561+ } ) ;
562+ text = extracted . text ;
563+ title =
564+ file . title ?? extracted . title ?? file . filename ?? "untitled" ;
565+ }
566+
567+ const { visibility, blockPrincipalIds } =
568+ resolveShareAndVisibility ( params ) ;
569+
570+ return store . add ( {
571+ tenantId : params . tenantId ,
572+ principalId : params . principalId ,
573+ title,
574+ text,
575+ visibility,
576+ ...( blockPrincipalIds !== undefined ? { blockPrincipalIds } : { } ) ,
577+ ...( params . attributes !== undefined
578+ ? { attributes : params . attributes }
579+ : { } ) ,
580+ ...( params . externalRef !== undefined
581+ ? { externalRef : params . externalRef }
582+ : { } ) ,
583+ } ) ;
584+ } ,
585+
586+ async recent ( params ) {
587+ const limit = resolveRecentLimit ( params . limit ) ;
588+ return store . recent ( {
589+ tenantId : params . tenantId ,
590+ principalId : params . principalId ,
591+ ...( limit !== undefined ? { limit } : { } ) ,
592+ } ) ;
593+ } ,
594+
595+ async close ( ) {
596+ await store . close ( ) ;
597+ } ,
598+ } ;
599+
600+ return plane ;
601+ }
602+
603+ /**
604+ * Default plane: engine pgvector store + hybrid search.
605+ */
606+ function createPlaneFromEngine (
607+ config : KnowledgeConfig ,
608+ grants : GrantConfig | undefined ,
609+ options : KnowledgePlaneOptions ,
416610) : KnowledgePlane {
417611 // Catch a chunk-size / reranker-limit mismatch at construction time, rather
418612 // than silently on every find once the reranker starts rejecting batches.
@@ -561,7 +755,7 @@ export function createKnowledgePlane(
561755 // data layers are independent and BOTH must allow. Per-document
562756 // visibility (enforced inside `find`) is not a substitute for "may
563757 // this principal search at all".
564- // Same action as HTTP find/ask/recent: knowledge:find.
758+ // Same action as HTTP find/ask/recent: knowledge:find.
565759 if ( ! grants ) {
566760 throw new KnowledgeError (
567761 501 ,
0 commit comments