155 lines
5.3 KiB
TypeScript
155 lines
5.3 KiB
TypeScript
import type { Insight, InsightSearchResult, MemoryAdapter } from './types.js';
|
|
|
|
const STARTUP_CONTEXT_CANDIDATE_LIMIT = 64;
|
|
|
|
/** Immutable server-derived boundary; callers never choose an adapter namespace. */
|
|
export interface OperatorMemoryScope {
|
|
readonly tenantId: string;
|
|
readonly ownerId: string;
|
|
readonly sessionId: string;
|
|
}
|
|
|
|
export interface OperatorMemoryConfig {
|
|
/** Adapter injection is deployment/lifecycle configuration, never caller input. */
|
|
readonly adapter: MemoryAdapter;
|
|
/** Configured agent identity; it is metadata rather than a storage key default. */
|
|
readonly instanceId: string;
|
|
/** Configured storage partition; callers cannot select a namespace. */
|
|
readonly namespace: string;
|
|
readonly maxStartupContext?: number;
|
|
redact(content: string): string;
|
|
}
|
|
|
|
export interface OperatorMemoryResult extends InsightSearchResult {
|
|
provenance: { instanceId: string; namespace: string; source: string };
|
|
}
|
|
|
|
export interface OperatorMemoryPlugin {
|
|
capture(
|
|
scope: OperatorMemoryScope,
|
|
input: { content: string; source: string; category: string },
|
|
): Promise<Insight>;
|
|
search(
|
|
scope: OperatorMemoryScope,
|
|
query: string,
|
|
limit?: number,
|
|
): Promise<OperatorMemoryResult[]>;
|
|
recent(scope: OperatorMemoryScope, limit?: number): Promise<OperatorMemoryResult[]>;
|
|
stats(scope: OperatorMemoryScope): Promise<{ namespace: string; resultCount: number }>;
|
|
startupContext(scope: OperatorMemoryScope): Promise<OperatorMemoryResult[]>;
|
|
}
|
|
|
|
function scopedUserId(scope: OperatorMemoryScope, namespace: string): string {
|
|
const normalizedScope = normalizeScope(scope);
|
|
// JSON tuple encoding avoids delimiter collisions between independently scoped IDs.
|
|
return JSON.stringify([
|
|
namespace,
|
|
normalizedScope.tenantId,
|
|
normalizedScope.ownerId,
|
|
normalizedScope.sessionId,
|
|
]);
|
|
}
|
|
|
|
function normalizeScope(scope: OperatorMemoryScope): OperatorMemoryScope {
|
|
if (typeof scope !== 'object' || scope === null) {
|
|
throw new Error('Operator memory scope is required');
|
|
}
|
|
return Object.freeze({
|
|
tenantId: requiredScopeId(scope.tenantId, 'tenant ID'),
|
|
ownerId: requiredScopeId(scope.ownerId, 'owner ID'),
|
|
sessionId: requiredScopeId(scope.sessionId, 'session ID'),
|
|
});
|
|
}
|
|
|
|
function requiredScopeId(value: unknown, field: string): string {
|
|
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
throw new Error(`Operator memory ${field} is required`);
|
|
}
|
|
return value.trim();
|
|
}
|
|
|
|
function compareStartupContext(left: OperatorMemoryResult, right: OperatorMemoryResult): number {
|
|
return (
|
|
startupSourcePriority(left.provenance.source) - startupSourcePriority(right.provenance.source)
|
|
);
|
|
}
|
|
|
|
function startupSourcePriority(source: string): number {
|
|
if (source === 'project') return 0;
|
|
if (source === 'flat-file') return 1;
|
|
return 2;
|
|
}
|
|
|
|
function normalizeConfig(config: OperatorMemoryConfig): OperatorMemoryConfig {
|
|
const instanceId = config.instanceId.trim();
|
|
const namespace = config.namespace.trim();
|
|
const maxStartupContext = config.maxStartupContext ?? 8;
|
|
if (instanceId.length === 0 || namespace.length === 0) {
|
|
throw new Error('Operator memory instance ID and namespace must be configured');
|
|
}
|
|
if (!Number.isSafeInteger(maxStartupContext) || maxStartupContext < 1) {
|
|
throw new Error('Operator memory startup context limit must be a positive integer');
|
|
}
|
|
return Object.freeze({ ...config, instanceId, namespace, maxStartupContext });
|
|
}
|
|
|
|
/** Creates a leaf-package, replaceable memory adapter facade. */
|
|
export function createOperatorMemoryPlugin(config: OperatorMemoryConfig): OperatorMemoryPlugin {
|
|
const pluginConfig = normalizeConfig(config);
|
|
const mapResult = (result: InsightSearchResult): OperatorMemoryResult => ({
|
|
...result,
|
|
provenance: {
|
|
instanceId: pluginConfig.instanceId,
|
|
namespace: pluginConfig.namespace,
|
|
source: String(result.metadata?.['source'] ?? 'retrieval'),
|
|
},
|
|
});
|
|
const search = async (
|
|
scope: OperatorMemoryScope,
|
|
query: string,
|
|
limit = 10,
|
|
): Promise<OperatorMemoryResult[]> =>
|
|
(
|
|
await pluginConfig.adapter.searchInsights(
|
|
scopedUserId(scope, pluginConfig.namespace),
|
|
query,
|
|
{
|
|
limit,
|
|
},
|
|
)
|
|
).map(mapResult);
|
|
return {
|
|
async capture(scope, input) {
|
|
return pluginConfig.adapter.storeInsight({
|
|
userId: scopedUserId(scope, pluginConfig.namespace),
|
|
content: pluginConfig.redact(input.content),
|
|
source: input.source,
|
|
category: input.category,
|
|
relevanceScore: 1,
|
|
metadata: {
|
|
namespace: pluginConfig.namespace,
|
|
instanceId: pluginConfig.instanceId,
|
|
source: input.source,
|
|
},
|
|
});
|
|
},
|
|
search,
|
|
async recent(scope, limit = 10) {
|
|
return search(scope, '*', limit);
|
|
},
|
|
async stats(scope) {
|
|
return {
|
|
namespace: pluginConfig.namespace,
|
|
resultCount: (await search(scope, '*', 100)).length,
|
|
};
|
|
},
|
|
async startupContext(scope) {
|
|
const maxStartupContext = pluginConfig.maxStartupContext ?? 8;
|
|
// Prioritize authoritative sources within a bounded candidate window.
|
|
const candidateLimit = Math.max(maxStartupContext, STARTUP_CONTEXT_CANDIDATE_LIMIT);
|
|
const context = await search(scope, '*', candidateLimit);
|
|
return [...context].sort(compareStartupContext).slice(0, maxStartupContext);
|
|
},
|
|
};
|
|
}
|