@fgv/ts-agent-memory 5.1.0-36
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/.rush/temp/chunked-rush-logs/ts-agent-memory.build.chunks.jsonl +9 -0
- package/.rush/temp/f6a88bfdd66517ccb98c2c7ae1be6e6fe9e15d38.tar.log +282 -0
- package/.rush/temp/operation/build/all.log +9 -0
- package/.rush/temp/operation/build/log-chunks.jsonl +9 -0
- package/.rush/temp/operation/build/state.json +3 -0
- package/.rush/temp/shrinkwrap-deps.json +688 -0
- package/LICENSE +21 -0
- package/README.md +45 -0
- package/config/api-extractor.json +343 -0
- package/config/jest.config.json +14 -0
- package/config/rig.json +4 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/packlets/converters/bodyConverterRegistry.js +51 -0
- package/dist/packlets/converters/bodyConverterRegistry.js.map +1 -0
- package/dist/packlets/converters/envelopeConverter.js +159 -0
- package/dist/packlets/converters/envelopeConverter.js.map +1 -0
- package/dist/packlets/converters/index.js +7 -0
- package/dist/packlets/converters/index.js.map +1 -0
- package/dist/packlets/index/index.js +6 -0
- package/dist/packlets/index/index.js.map +1 -0
- package/dist/packlets/index/memoryIndex.js +170 -0
- package/dist/packlets/index/memoryIndex.js.map +1 -0
- package/dist/packlets/observe/index.js +7 -0
- package/dist/packlets/observe/index.js.map +1 -0
- package/dist/packlets/observe/memoryObservationStore.js +119 -0
- package/dist/packlets/observe/memoryObservationStore.js.map +1 -0
- package/dist/packlets/observe/observer.js +6 -0
- package/dist/packlets/observe/observer.js.map +1 -0
- package/dist/packlets/retrieve/hybridRetriever.js +135 -0
- package/dist/packlets/retrieve/hybridRetriever.js.map +1 -0
- package/dist/packlets/retrieve/index.js +12 -0
- package/dist/packlets/retrieve/index.js.map +1 -0
- package/dist/packlets/retrieve/linkTraversalRetriever.js +143 -0
- package/dist/packlets/retrieve/linkTraversalRetriever.js.map +1 -0
- package/dist/packlets/retrieve/recencyRetriever.js +33 -0
- package/dist/packlets/retrieve/recencyRetriever.js.map +1 -0
- package/dist/packlets/retrieve/retriever.js +110 -0
- package/dist/packlets/retrieve/retriever.js.map +1 -0
- package/dist/packlets/retrieve/semanticRetriever.js +86 -0
- package/dist/packlets/retrieve/semanticRetriever.js.map +1 -0
- package/dist/packlets/retrieve/structuredFilterRetriever.js +37 -0
- package/dist/packlets/retrieve/structuredFilterRetriever.js.map +1 -0
- package/dist/packlets/retrieve/tagRetriever.js +37 -0
- package/dist/packlets/retrieve/tagRetriever.js.map +1 -0
- package/dist/packlets/store/fileTreeMemoryStore.js +698 -0
- package/dist/packlets/store/fileTreeMemoryStore.js.map +1 -0
- package/dist/packlets/store/index.js +7 -0
- package/dist/packlets/store/index.js.map +1 -0
- package/dist/packlets/store/scopeEncoding.js +31 -0
- package/dist/packlets/store/scopeEncoding.js.map +1 -0
- package/dist/packlets/types/envelope.js +6 -0
- package/dist/packlets/types/envelope.js.map +1 -0
- package/dist/packlets/types/filenameSafety.js +52 -0
- package/dist/packlets/types/filenameSafety.js.map +1 -0
- package/dist/packlets/types/identityCodec.js +184 -0
- package/dist/packlets/types/identityCodec.js.map +1 -0
- package/dist/packlets/types/ids.js +67 -0
- package/dist/packlets/types/ids.js.map +1 -0
- package/dist/packlets/types/index.js +10 -0
- package/dist/packlets/types/index.js.map +1 -0
- package/dist/packlets/types/writePolicy.js +263 -0
- package/dist/packlets/types/writePolicy.js.map +1 -0
- package/dist/packlets/vector/inMemoryCosineIndex.js +150 -0
- package/dist/packlets/vector/inMemoryCosineIndex.js.map +1 -0
- package/dist/packlets/vector/index.js +7 -0
- package/dist/packlets/vector/index.js.map +1 -0
- package/dist/packlets/vector/vectorIndex.js +6 -0
- package/dist/packlets/vector/vectorIndex.js.map +1 -0
- package/dist/test/unit/converters/bodyConverterRegistry.test.js +72 -0
- package/dist/test/unit/converters/bodyConverterRegistry.test.js.map +1 -0
- package/dist/test/unit/converters/envelopeConverter.test.js +196 -0
- package/dist/test/unit/converters/envelopeConverter.test.js.map +1 -0
- package/dist/test/unit/index/memoryIndex.test.js +152 -0
- package/dist/test/unit/index/memoryIndex.test.js.map +1 -0
- package/dist/test/unit/observe/memoryObservationStore.test.js +118 -0
- package/dist/test/unit/observe/memoryObservationStore.test.js.map +1 -0
- package/dist/test/unit/retrieve/linkTraversalRetriever.test.js +182 -0
- package/dist/test/unit/retrieve/linkTraversalRetriever.test.js.map +1 -0
- package/dist/test/unit/retrieve/retrievers.test.js +506 -0
- package/dist/test/unit/retrieve/retrievers.test.js.map +1 -0
- package/dist/test/unit/store/embedOnWrite.test.js +260 -0
- package/dist/test/unit/store/embedOnWrite.test.js.map +1 -0
- package/dist/test/unit/store/fileTreeMemoryStore.test.js +647 -0
- package/dist/test/unit/store/fileTreeMemoryStore.test.js.map +1 -0
- package/dist/test/unit/store/observations.test.js +239 -0
- package/dist/test/unit/store/observations.test.js.map +1 -0
- package/dist/test/unit/store/scopeEncoding.test.js +24 -0
- package/dist/test/unit/store/scopeEncoding.test.js.map +1 -0
- package/dist/test/unit/types/identityCodec.test.js +187 -0
- package/dist/test/unit/types/identityCodec.test.js.map +1 -0
- package/dist/test/unit/types/ids.test.js +84 -0
- package/dist/test/unit/types/ids.test.js.map +1 -0
- package/dist/test/unit/types/writePolicy.test.js +241 -0
- package/dist/test/unit/types/writePolicy.test.js.map +1 -0
- package/dist/test/unit/vector/inMemoryCosineIndex.test.js +192 -0
- package/dist/test/unit/vector/inMemoryCosineIndex.test.js.map +1 -0
- package/dist/test/unit/vector/vectorIndex.test.js +42 -0
- package/dist/test/unit/vector/vectorIndex.test.js.map +1 -0
- package/dist/ts-agent-memory.d.ts +1901 -0
- package/dist/tsdoc-metadata.json +11 -0
- package/eslint.config.js +15 -0
- package/etc/ts-agent-memory.api.md +525 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +28 -0
- package/lib/index.js.map +1 -0
- package/lib/packlets/converters/bodyConverterRegistry.d.ts +64 -0
- package/lib/packlets/converters/bodyConverterRegistry.d.ts.map +1 -0
- package/lib/packlets/converters/bodyConverterRegistry.js +55 -0
- package/lib/packlets/converters/bodyConverterRegistry.js.map +1 -0
- package/lib/packlets/converters/envelopeConverter.d.ts +70 -0
- package/lib/packlets/converters/envelopeConverter.d.ts.map +1 -0
- package/lib/packlets/converters/envelopeConverter.js +166 -0
- package/lib/packlets/converters/envelopeConverter.js.map +1 -0
- package/lib/packlets/converters/index.d.ts +3 -0
- package/lib/packlets/converters/index.d.ts.map +1 -0
- package/lib/packlets/converters/index.js +23 -0
- package/lib/packlets/converters/index.js.map +1 -0
- package/lib/packlets/index/index.d.ts +2 -0
- package/lib/packlets/index/index.d.ts.map +1 -0
- package/lib/packlets/index/index.js +22 -0
- package/lib/packlets/index/index.js.map +1 -0
- package/lib/packlets/index/memoryIndex.d.ts +127 -0
- package/lib/packlets/index/memoryIndex.d.ts.map +1 -0
- package/lib/packlets/index/memoryIndex.js +174 -0
- package/lib/packlets/index/memoryIndex.js.map +1 -0
- package/lib/packlets/observe/index.d.ts +3 -0
- package/lib/packlets/observe/index.d.ts.map +1 -0
- package/lib/packlets/observe/index.js +23 -0
- package/lib/packlets/observe/index.js.map +1 -0
- package/lib/packlets/observe/memoryObservationStore.d.ts +91 -0
- package/lib/packlets/observe/memoryObservationStore.d.ts.map +1 -0
- package/lib/packlets/observe/memoryObservationStore.js +123 -0
- package/lib/packlets/observe/memoryObservationStore.js.map +1 -0
- package/lib/packlets/observe/observer.d.ts +110 -0
- package/lib/packlets/observe/observer.d.ts.map +1 -0
- package/lib/packlets/observe/observer.js +7 -0
- package/lib/packlets/observe/observer.js.map +1 -0
- package/lib/packlets/retrieve/hybridRetriever.d.ts +79 -0
- package/lib/packlets/retrieve/hybridRetriever.d.ts.map +1 -0
- package/lib/packlets/retrieve/hybridRetriever.js +140 -0
- package/lib/packlets/retrieve/hybridRetriever.js.map +1 -0
- package/lib/packlets/retrieve/index.d.ts +8 -0
- package/lib/packlets/retrieve/index.d.ts.map +1 -0
- package/lib/packlets/retrieve/index.js +28 -0
- package/lib/packlets/retrieve/index.js.map +1 -0
- package/lib/packlets/retrieve/linkTraversalRetriever.d.ts +61 -0
- package/lib/packlets/retrieve/linkTraversalRetriever.d.ts.map +1 -0
- package/lib/packlets/retrieve/linkTraversalRetriever.js +147 -0
- package/lib/packlets/retrieve/linkTraversalRetriever.js.map +1 -0
- package/lib/packlets/retrieve/recencyRetriever.d.ts +21 -0
- package/lib/packlets/retrieve/recencyRetriever.d.ts.map +1 -0
- package/lib/packlets/retrieve/recencyRetriever.js +37 -0
- package/lib/packlets/retrieve/recencyRetriever.js.map +1 -0
- package/lib/packlets/retrieve/retriever.d.ts +133 -0
- package/lib/packlets/retrieve/retriever.d.ts.map +1 -0
- package/lib/packlets/retrieve/retriever.js +119 -0
- package/lib/packlets/retrieve/retriever.js.map +1 -0
- package/lib/packlets/retrieve/semanticRetriever.d.ts +69 -0
- package/lib/packlets/retrieve/semanticRetriever.d.ts.map +1 -0
- package/lib/packlets/retrieve/semanticRetriever.js +90 -0
- package/lib/packlets/retrieve/semanticRetriever.js.map +1 -0
- package/lib/packlets/retrieve/structuredFilterRetriever.d.ts +22 -0
- package/lib/packlets/retrieve/structuredFilterRetriever.d.ts.map +1 -0
- package/lib/packlets/retrieve/structuredFilterRetriever.js +41 -0
- package/lib/packlets/retrieve/structuredFilterRetriever.js.map +1 -0
- package/lib/packlets/retrieve/tagRetriever.d.ts +22 -0
- package/lib/packlets/retrieve/tagRetriever.d.ts.map +1 -0
- package/lib/packlets/retrieve/tagRetriever.js +41 -0
- package/lib/packlets/retrieve/tagRetriever.js.map +1 -0
- package/lib/packlets/store/fileTreeMemoryStore.d.ts +327 -0
- package/lib/packlets/store/fileTreeMemoryStore.d.ts.map +1 -0
- package/lib/packlets/store/fileTreeMemoryStore.js +702 -0
- package/lib/packlets/store/fileTreeMemoryStore.js.map +1 -0
- package/lib/packlets/store/index.d.ts +3 -0
- package/lib/packlets/store/index.d.ts.map +1 -0
- package/lib/packlets/store/index.js +23 -0
- package/lib/packlets/store/index.js.map +1 -0
- package/lib/packlets/store/scopeEncoding.d.ts +19 -0
- package/lib/packlets/store/scopeEncoding.d.ts.map +1 -0
- package/lib/packlets/store/scopeEncoding.js +34 -0
- package/lib/packlets/store/scopeEncoding.js.map +1 -0
- package/lib/packlets/types/envelope.d.ts +119 -0
- package/lib/packlets/types/envelope.d.ts.map +1 -0
- package/lib/packlets/types/envelope.js +7 -0
- package/lib/packlets/types/envelope.js.map +1 -0
- package/lib/packlets/types/filenameSafety.d.ts +16 -0
- package/lib/packlets/types/filenameSafety.d.ts.map +1 -0
- package/lib/packlets/types/filenameSafety.js +55 -0
- package/lib/packlets/types/filenameSafety.js.map +1 -0
- package/lib/packlets/types/identityCodec.d.ts +136 -0
- package/lib/packlets/types/identityCodec.d.ts.map +1 -0
- package/lib/packlets/types/identityCodec.js +190 -0
- package/lib/packlets/types/identityCodec.js.map +1 -0
- package/lib/packlets/types/ids.d.ts +55 -0
- package/lib/packlets/types/ids.d.ts.map +1 -0
- package/lib/packlets/types/ids.js +70 -0
- package/lib/packlets/types/ids.js.map +1 -0
- package/lib/packlets/types/index.d.ts +6 -0
- package/lib/packlets/types/index.d.ts.map +1 -0
- package/lib/packlets/types/index.js +26 -0
- package/lib/packlets/types/index.js.map +1 -0
- package/lib/packlets/types/writePolicy.d.ts +213 -0
- package/lib/packlets/types/writePolicy.d.ts.map +1 -0
- package/lib/packlets/types/writePolicy.js +268 -0
- package/lib/packlets/types/writePolicy.js.map +1 -0
- package/lib/packlets/vector/inMemoryCosineIndex.d.ts +69 -0
- package/lib/packlets/vector/inMemoryCosineIndex.d.ts.map +1 -0
- package/lib/packlets/vector/inMemoryCosineIndex.js +154 -0
- package/lib/packlets/vector/inMemoryCosineIndex.js.map +1 -0
- package/lib/packlets/vector/index.d.ts +3 -0
- package/lib/packlets/vector/index.d.ts.map +1 -0
- package/lib/packlets/vector/index.js +23 -0
- package/lib/packlets/vector/index.js.map +1 -0
- package/lib/packlets/vector/vectorIndex.d.ts +68 -0
- package/lib/packlets/vector/vectorIndex.d.ts.map +1 -0
- package/lib/packlets/vector/vectorIndex.js +7 -0
- package/lib/packlets/vector/vectorIndex.js.map +1 -0
- package/lib/test/unit/converters/bodyConverterRegistry.test.d.ts +2 -0
- package/lib/test/unit/converters/bodyConverterRegistry.test.d.ts.map +1 -0
- package/lib/test/unit/converters/bodyConverterRegistry.test.js +74 -0
- package/lib/test/unit/converters/bodyConverterRegistry.test.js.map +1 -0
- package/lib/test/unit/converters/envelopeConverter.test.d.ts +2 -0
- package/lib/test/unit/converters/envelopeConverter.test.d.ts.map +1 -0
- package/lib/test/unit/converters/envelopeConverter.test.js +198 -0
- package/lib/test/unit/converters/envelopeConverter.test.js.map +1 -0
- package/lib/test/unit/index/memoryIndex.test.d.ts +2 -0
- package/lib/test/unit/index/memoryIndex.test.d.ts.map +1 -0
- package/lib/test/unit/index/memoryIndex.test.js +154 -0
- package/lib/test/unit/index/memoryIndex.test.js.map +1 -0
- package/lib/test/unit/observe/memoryObservationStore.test.d.ts +2 -0
- package/lib/test/unit/observe/memoryObservationStore.test.d.ts.map +1 -0
- package/lib/test/unit/observe/memoryObservationStore.test.js +120 -0
- package/lib/test/unit/observe/memoryObservationStore.test.js.map +1 -0
- package/lib/test/unit/retrieve/linkTraversalRetriever.test.d.ts +2 -0
- package/lib/test/unit/retrieve/linkTraversalRetriever.test.d.ts.map +1 -0
- package/lib/test/unit/retrieve/linkTraversalRetriever.test.js +184 -0
- package/lib/test/unit/retrieve/linkTraversalRetriever.test.js.map +1 -0
- package/lib/test/unit/retrieve/retrievers.test.d.ts +2 -0
- package/lib/test/unit/retrieve/retrievers.test.d.ts.map +1 -0
- package/lib/test/unit/retrieve/retrievers.test.js +508 -0
- package/lib/test/unit/retrieve/retrievers.test.js.map +1 -0
- package/lib/test/unit/store/embedOnWrite.test.d.ts +2 -0
- package/lib/test/unit/store/embedOnWrite.test.d.ts.map +1 -0
- package/lib/test/unit/store/embedOnWrite.test.js +262 -0
- package/lib/test/unit/store/embedOnWrite.test.js.map +1 -0
- package/lib/test/unit/store/fileTreeMemoryStore.test.d.ts +2 -0
- package/lib/test/unit/store/fileTreeMemoryStore.test.d.ts.map +1 -0
- package/lib/test/unit/store/fileTreeMemoryStore.test.js +649 -0
- package/lib/test/unit/store/fileTreeMemoryStore.test.js.map +1 -0
- package/lib/test/unit/store/observations.test.d.ts +2 -0
- package/lib/test/unit/store/observations.test.d.ts.map +1 -0
- package/lib/test/unit/store/observations.test.js +241 -0
- package/lib/test/unit/store/observations.test.js.map +1 -0
- package/lib/test/unit/store/scopeEncoding.test.d.ts +2 -0
- package/lib/test/unit/store/scopeEncoding.test.d.ts.map +1 -0
- package/lib/test/unit/store/scopeEncoding.test.js +26 -0
- package/lib/test/unit/store/scopeEncoding.test.js.map +1 -0
- package/lib/test/unit/types/identityCodec.test.d.ts +2 -0
- package/lib/test/unit/types/identityCodec.test.d.ts.map +1 -0
- package/lib/test/unit/types/identityCodec.test.js +189 -0
- package/lib/test/unit/types/identityCodec.test.js.map +1 -0
- package/lib/test/unit/types/ids.test.d.ts +2 -0
- package/lib/test/unit/types/ids.test.d.ts.map +1 -0
- package/lib/test/unit/types/ids.test.js +86 -0
- package/lib/test/unit/types/ids.test.js.map +1 -0
- package/lib/test/unit/types/writePolicy.test.d.ts +2 -0
- package/lib/test/unit/types/writePolicy.test.d.ts.map +1 -0
- package/lib/test/unit/types/writePolicy.test.js +243 -0
- package/lib/test/unit/types/writePolicy.test.js.map +1 -0
- package/lib/test/unit/vector/inMemoryCosineIndex.test.d.ts +2 -0
- package/lib/test/unit/vector/inMemoryCosineIndex.test.d.ts.map +1 -0
- package/lib/test/unit/vector/inMemoryCosineIndex.test.js +194 -0
- package/lib/test/unit/vector/inMemoryCosineIndex.test.js.map +1 -0
- package/lib/test/unit/vector/vectorIndex.test.d.ts +2 -0
- package/lib/test/unit/vector/vectorIndex.test.d.ts.map +1 -0
- package/lib/test/unit/vector/vectorIndex.test.js +44 -0
- package/lib/test/unit/vector/vectorIndex.test.js.map +1 -0
- package/package.json +81 -0
- package/rush-logs/ts-agent-memory.build.cache.log +3 -0
- package/rush-logs/ts-agent-memory.build.log +9 -0
- package/src/index.ts +12 -0
- package/src/packlets/converters/bodyConverterRegistry.ts +105 -0
- package/src/packlets/converters/envelopeConverter.ts +210 -0
- package/src/packlets/converters/index.ts +7 -0
- package/src/packlets/index/index.ts +6 -0
- package/src/packlets/index/memoryIndex.ts +268 -0
- package/src/packlets/observe/index.ts +7 -0
- package/src/packlets/observe/memoryObservationStore.ts +153 -0
- package/src/packlets/observe/observer.ts +119 -0
- package/src/packlets/retrieve/hybridRetriever.ts +181 -0
- package/src/packlets/retrieve/index.ts +12 -0
- package/src/packlets/retrieve/linkTraversalRetriever.ts +169 -0
- package/src/packlets/retrieve/recencyRetriever.ts +54 -0
- package/src/packlets/retrieve/retriever.ts +207 -0
- package/src/packlets/retrieve/semanticRetriever.ts +147 -0
- package/src/packlets/retrieve/structuredFilterRetriever.ts +58 -0
- package/src/packlets/retrieve/tagRetriever.ts +58 -0
- package/src/packlets/store/fileTreeMemoryStore.ts +1073 -0
- package/src/packlets/store/index.ts +7 -0
- package/src/packlets/store/scopeEncoding.ts +36 -0
- package/src/packlets/types/envelope.ts +138 -0
- package/src/packlets/types/filenameSafety.ts +57 -0
- package/src/packlets/types/identityCodec.ts +263 -0
- package/src/packlets/types/ids.ts +124 -0
- package/src/packlets/types/index.ts +10 -0
- package/src/packlets/types/writePolicy.ts +447 -0
- package/src/packlets/vector/inMemoryCosineIndex.ts +173 -0
- package/src/packlets/vector/index.ts +7 -0
- package/src/packlets/vector/vectorIndex.ts +78 -0
- package/src/test/unit/converters/bodyConverterRegistry.test.ts +89 -0
- package/src/test/unit/converters/envelopeConverter.test.ts +261 -0
- package/src/test/unit/index/memoryIndex.test.ts +187 -0
- package/src/test/unit/observe/memoryObservationStore.test.ts +158 -0
- package/src/test/unit/retrieve/linkTraversalRetriever.test.ts +230 -0
- package/src/test/unit/retrieve/retrievers.test.ts +662 -0
- package/src/test/unit/store/embedOnWrite.test.ts +346 -0
- package/src/test/unit/store/fileTreeMemoryStore.test.ts +875 -0
- package/src/test/unit/store/observations.test.ts +290 -0
- package/src/test/unit/store/scopeEncoding.test.ts +37 -0
- package/src/test/unit/types/identityCodec.test.ts +266 -0
- package/src/test/unit/types/ids.test.ts +94 -0
- package/src/test/unit/types/writePolicy.test.ts +325 -0
- package/src/test/unit/vector/inMemoryCosineIndex.test.ts +242 -0
- package/src/test/unit/vector/vectorIndex.test.ts +48 -0
- package/temp/build/lint/_eslint-5eVG3S6w.json +182 -0
- package/temp/build/typescript/ts_8nwakTlr.json +1 -0
- package/temp/ts-agent-memory.api.json +12438 -0
- package/temp/ts-agent-memory.api.md +525 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2026 Erik Fortune
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Result, fail, mapResults, succeed } from '@fgv/ts-utils';
|
|
7
|
+
import { MemoryScopeKey, assertPortableFilenameStem } from '../types';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Encode a {@link MemoryScopeKey} to its on-disk directory path. The scope may
|
|
11
|
+
* be multi-segment (`/`-separated) — each component is validated independently
|
|
12
|
+
* against the POSIX portable filename set (via
|
|
13
|
+
* {@link assertPortableFilenameStem}), then rejoined with `/`.
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* This is the day-one multi-segment scope resolver design-lock §9.1 calls for:
|
|
17
|
+
* the knowledge scope is the single segment `knowledge`, while the Phase-C MTM
|
|
18
|
+
* scope `conversations/<conversationId>` is two segments. Validating each
|
|
19
|
+
* segment independently (rather than the whole path as one stem, which the
|
|
20
|
+
* `ts-prompt-assist` default encoding does) keeps the Phase-C codec additive —
|
|
21
|
+
* no scope-encoding change is needed when MTM ships.
|
|
22
|
+
* @public
|
|
23
|
+
*/
|
|
24
|
+
export function defaultMemoryScopeEncoding(scope: MemoryScopeKey): Result<string> {
|
|
25
|
+
// `''.split('/')` yields `['']`, never `[]`, so a single empty-segment check
|
|
26
|
+
// covers the empty-scope case too.
|
|
27
|
+
const segments: string[] = scope.split('/');
|
|
28
|
+
if (segments.some((s) => s.length === 0)) {
|
|
29
|
+
return fail(`scope '${scope}': must not contain empty path segments`);
|
|
30
|
+
}
|
|
31
|
+
return mapResults(
|
|
32
|
+
segments.map((segment) =>
|
|
33
|
+
assertPortableFilenameStem(segment).withErrorFormat((msg) => `scope '${scope}': ${msg}`)
|
|
34
|
+
)
|
|
35
|
+
).onSuccess((validated) => succeed(validated.join('/')));
|
|
36
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2026 Erik Fortune
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { EntityId, Kind, LinkType, MemoryId, Tag } from './ids';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Origin of a provenance attribution. Open vocabulary: the three named
|
|
10
|
+
* sources are conventional, but the `(string & {})` arm admits any other
|
|
11
|
+
* source string without resignature.
|
|
12
|
+
* @public
|
|
13
|
+
*/
|
|
14
|
+
export type ProvenanceSource = 'agent' | 'host-ingest' | 'human' | (string & {});
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Structured provenance for a record or an edge. Never a flat enum — the
|
|
18
|
+
* `[key: string]: unknown` index signature lets a consumer attach an opaque
|
|
19
|
+
* domain payload (e.g. PersonAIlity's sentiment / epistemic blocks) without
|
|
20
|
+
* changing this interface, while still satisfying the no-`any` rule.
|
|
21
|
+
* @public
|
|
22
|
+
*/
|
|
23
|
+
export interface IProvenance {
|
|
24
|
+
/** Where the attribution came from. */
|
|
25
|
+
readonly source: ProvenanceSource;
|
|
26
|
+
/** Optional human or agent identifier responsible for the write. */
|
|
27
|
+
readonly by?: string;
|
|
28
|
+
/** Optional model identifier, when a model produced the content. */
|
|
29
|
+
readonly model?: string;
|
|
30
|
+
/** Optional confidence in `[0, 1]`. */
|
|
31
|
+
readonly confidence?: number;
|
|
32
|
+
/** Back-link to the source experience record. Enables the cross-kind provenance spine. */
|
|
33
|
+
readonly derivedFrom?: MemoryId;
|
|
34
|
+
/** Opaque extension payload — consumer-owned, never interpreted by the store. */
|
|
35
|
+
readonly [key: string]: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* An attributed link between two records. Carries the relation type, the
|
|
40
|
+
* target id, and optional confidence / provenance / world-truth validity.
|
|
41
|
+
* Replaces bare string references (e.g. PersonAIlity's `IMtmRef` becomes an
|
|
42
|
+
* `IEdge` with `type: LinkType('mtm-ref')`).
|
|
43
|
+
* @public
|
|
44
|
+
*/
|
|
45
|
+
export interface IEdge {
|
|
46
|
+
/** Open-vocabulary relation type. */
|
|
47
|
+
readonly type: LinkType;
|
|
48
|
+
/** The linked-to record. */
|
|
49
|
+
readonly target: MemoryId;
|
|
50
|
+
/** Optional confidence in `[0, 1]`. */
|
|
51
|
+
readonly confidence?: number;
|
|
52
|
+
/** Optional structured provenance for the link itself. */
|
|
53
|
+
readonly provenance?: IProvenance;
|
|
54
|
+
/** World-truth validity start (epoch ms). Present only on temporal edges. */
|
|
55
|
+
readonly valid_at?: number;
|
|
56
|
+
/**
|
|
57
|
+
* World-truth validity end (epoch ms). `null` = still valid; absent = no
|
|
58
|
+
* temporal extent.
|
|
59
|
+
*/
|
|
60
|
+
// eslint-disable-next-line @rushstack/no-new-null -- null is a meaningful value here (still-valid) distinct from absent (no temporal extent); design-lock §2.3
|
|
61
|
+
readonly invalid_at?: number | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Optional bi-temporal validity block on an envelope. Present only on
|
|
66
|
+
* temporal kinds; absent = zero cost for atemporal kinds.
|
|
67
|
+
* @public
|
|
68
|
+
*/
|
|
69
|
+
export interface ITemporalBlock {
|
|
70
|
+
/** World-truth validity start (epoch ms). */
|
|
71
|
+
readonly valid_at?: number;
|
|
72
|
+
/** World-truth validity end (epoch ms). `null` = still valid. */
|
|
73
|
+
// eslint-disable-next-line @rushstack/no-new-null -- null is a meaningful value here (still-valid) distinct from absent; design-lock §2.4
|
|
74
|
+
readonly invalid_at?: number | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The invariant identity + transaction-time envelope carried by every memory
|
|
79
|
+
* record, independent of the per-kind body.
|
|
80
|
+
* @public
|
|
81
|
+
*/
|
|
82
|
+
export interface IMemoryEnvelope {
|
|
83
|
+
// --- Core identity ---
|
|
84
|
+
/** Stable file-stem identifier. MUST equal the on-disk filename stem. */
|
|
85
|
+
readonly id: MemoryId;
|
|
86
|
+
/** Consumer-supplied domain key. Equals {@link IMemoryEnvelope.id | id} for non-temporal kinds. */
|
|
87
|
+
readonly entityId: EntityId;
|
|
88
|
+
/** Consumer-registered kind; dispatches the body Converter. */
|
|
89
|
+
readonly kind: Kind;
|
|
90
|
+
/** Open-vocabulary tags. */
|
|
91
|
+
readonly tags: ReadonlyArray<Tag>;
|
|
92
|
+
/** Attributed outbound edges. */
|
|
93
|
+
readonly links: ReadonlyArray<IEdge>;
|
|
94
|
+
|
|
95
|
+
// --- Transaction-time metadata (always present) ---
|
|
96
|
+
/** Epoch ms of the first write. Immutable after creation. */
|
|
97
|
+
readonly created: number;
|
|
98
|
+
/** Epoch ms of the most recent write. */
|
|
99
|
+
readonly updated: number;
|
|
100
|
+
/**
|
|
101
|
+
* Monotonic write counter within the store instance, assigned by the store
|
|
102
|
+
* on every successful put. Enables stable cursor paging over observation
|
|
103
|
+
* records without a full walk.
|
|
104
|
+
*/
|
|
105
|
+
readonly seq: number;
|
|
106
|
+
/**
|
|
107
|
+
* Content hash over the canonical `{ kind, body, links }`. The dedup key:
|
|
108
|
+
* an exact match is a no-op upsert that returns the existing record.
|
|
109
|
+
*/
|
|
110
|
+
readonly contentHash: string;
|
|
111
|
+
/** Structured provenance (never a flat enum). */
|
|
112
|
+
readonly provenance: IProvenance;
|
|
113
|
+
|
|
114
|
+
// --- Optional temporal block ---
|
|
115
|
+
/** Bi-temporal validity. Present only on temporal kinds. */
|
|
116
|
+
readonly temporal?: ITemporalBlock;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Vector-index entry reference, set by the vector index on write. `null` =
|
|
120
|
+
* not embedded; absent = same as `null` (backwards-compat seam).
|
|
121
|
+
*/
|
|
122
|
+
// eslint-disable-next-line @rushstack/no-new-null -- null is the explicit "not embedded" sentinel distinct from absent (backwards-compat seam); design-lock §2.5
|
|
123
|
+
readonly embeddingRef?: string | null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A complete memory record: the invariant {@link IMemoryEnvelope} plus the
|
|
128
|
+
* typed, per-kind body. The store's public surface uses
|
|
129
|
+
* `IMemoryRecord<unknown>`; consumers narrow `TBody` by checking
|
|
130
|
+
* `envelope.kind` and validating through the registered Converter.
|
|
131
|
+
* @public
|
|
132
|
+
*/
|
|
133
|
+
export interface IMemoryRecord<TBody = unknown> {
|
|
134
|
+
/** The invariant identity + transaction-time envelope. */
|
|
135
|
+
readonly envelope: IMemoryEnvelope;
|
|
136
|
+
/** The per-kind, Converter-validated body. */
|
|
137
|
+
readonly body: TBody;
|
|
138
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2026 Erik Fortune
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Result, fail, succeed } from '@fgv/ts-utils';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Reserved Windows device basenames. Includes `COM0..9` and `LPT0..9` — the
|
|
10
|
+
* 0-suffixed variants were added in Windows 11 / Server 2022. Matched
|
|
11
|
+
* case-insensitively against the basename (text before the first `.`).
|
|
12
|
+
*/
|
|
13
|
+
const RESERVED_WIN_DEVICES: ReadonlySet<string> = new Set<string>([
|
|
14
|
+
'CON',
|
|
15
|
+
'PRN',
|
|
16
|
+
'AUX',
|
|
17
|
+
'NUL',
|
|
18
|
+
...Array.from({ length: 10 }, (__v, i) => `COM${i}`),
|
|
19
|
+
...Array.from({ length: 10 }, (__v, i) => `LPT${i}`)
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const PORTABLE_FILENAME_RE: RegExp = /^[A-Za-z0-9._-]+$/;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Validates a single filename stem against the POSIX portable filename set
|
|
26
|
+
* (`[A-Za-z0-9._-]`), rejecting a leading or trailing `.` and reserved Windows
|
|
27
|
+
* device names. This is the cross-platform filename-stem contract for the
|
|
28
|
+
* package: {@link MemoryId} values, the concrete {@link IIdentityCodec}
|
|
29
|
+
* implementations, and the store's `verifyFilenameId` all gate on it.
|
|
30
|
+
*
|
|
31
|
+
* @remarks
|
|
32
|
+
* A trailing `.` is rejected because Windows silently strips trailing dots from
|
|
33
|
+
* filenames, so such a stem would not round-trip verbatim through the file
|
|
34
|
+
* layer even though it is otherwise portable-set-valid.
|
|
35
|
+
* @public
|
|
36
|
+
*/
|
|
37
|
+
export function assertPortableFilenameStem(stem: string): Result<string> {
|
|
38
|
+
if (stem.length === 0) {
|
|
39
|
+
return fail('idStem: must be a non-empty string');
|
|
40
|
+
}
|
|
41
|
+
if (stem.startsWith('.')) {
|
|
42
|
+
return fail(`idStem '${stem}': may not begin with '.'`);
|
|
43
|
+
}
|
|
44
|
+
if (stem.endsWith('.')) {
|
|
45
|
+
return fail(`idStem '${stem}': may not end with '.' (Windows strips trailing dots)`);
|
|
46
|
+
}
|
|
47
|
+
if (!PORTABLE_FILENAME_RE.test(stem)) {
|
|
48
|
+
return fail(
|
|
49
|
+
`idStem '${stem}': contains characters outside the POSIX portable filename set ([A-Za-z0-9._-])`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const basename: string = stem.split('.')[0];
|
|
53
|
+
if (RESERVED_WIN_DEVICES.has(basename.toUpperCase())) {
|
|
54
|
+
return fail(`idStem '${stem}': basename '${basename}' matches a reserved Windows device name`);
|
|
55
|
+
}
|
|
56
|
+
return succeed(stem);
|
|
57
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2026 Erik Fortune
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Result, fail, succeed } from '@fgv/ts-utils';
|
|
7
|
+
import { Convert, EntityId, MemoryScopeKey } from './ids';
|
|
8
|
+
import { assertPortableFilenameStem } from './filenameSafety';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The FileTree storage address an {@link IIdentityCodec} maps a domain key to.
|
|
12
|
+
* @public
|
|
13
|
+
*/
|
|
14
|
+
export interface IIdentityCodecResult {
|
|
15
|
+
/** Scope path segment (may be multi-level, e.g. `conversations/<id>`). */
|
|
16
|
+
readonly scope: MemoryScopeKey;
|
|
17
|
+
/** Filename stem (the part before `.md`). Filename-safe after encoding. */
|
|
18
|
+
readonly idStem: string;
|
|
19
|
+
/**
|
|
20
|
+
* Whether this kind uses a versioned layout (temporal: multiple files per
|
|
21
|
+
* entity) vs. a flat layout (one file per entity). Non-temporal = always
|
|
22
|
+
* `false`.
|
|
23
|
+
*/
|
|
24
|
+
readonly isVersioned: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Maps a consumer-supplied domain key ⇄ a FileTree storage address. Injected
|
|
29
|
+
* per kind so the store never touches raw domain keys: the codec owns all
|
|
30
|
+
* filename escaping and the flat-vs-versioned layout dispatch.
|
|
31
|
+
* @public
|
|
32
|
+
*/
|
|
33
|
+
export interface IIdentityCodec {
|
|
34
|
+
/**
|
|
35
|
+
* Encode a consumer-supplied entity id to a FileTree address. Deterministic
|
|
36
|
+
* and pure — no I/O.
|
|
37
|
+
*/
|
|
38
|
+
encode(entityId: EntityId): Result<IIdentityCodecResult>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Decode a FileTree address back to the original {@link EntityId}. The exact
|
|
42
|
+
* inverse of {@link IIdentityCodec.encode | encode} for non-versioned kinds.
|
|
43
|
+
*/
|
|
44
|
+
decode(scope: MemoryScopeKey, encodedStem: string): Result<EntityId>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Assert that `encode(decode(scope, stem)).idStem === stem`. Used by the
|
|
48
|
+
* store's `verifyFilenameId` check on load.
|
|
49
|
+
*/
|
|
50
|
+
verifyRoundTrip(scope: MemoryScopeKey, stem: string): Result<true>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
/**
|
|
55
|
+
* Identity codec for the knowledge kind family. A knowledge entity is keyed
|
|
56
|
+
* by its consumer-supplied `docId`, which is used verbatim as the filename
|
|
57
|
+
* stem under the flat `knowledge` scope.
|
|
58
|
+
*
|
|
59
|
+
* @remarks
|
|
60
|
+
* - `encode`: scope = `knowledge`, idStem = `docId`, `isVersioned = false`.
|
|
61
|
+
* - `decode`: brands the stem back to an {@link EntityId}.
|
|
62
|
+
* - Escaping: the `docId` must match the POSIX portable filename set.
|
|
63
|
+
* - Layout: `vault/knowledge/<docId>.md`.
|
|
64
|
+
* @public
|
|
65
|
+
*/
|
|
66
|
+
export class KnowledgeIdentityCodec implements IIdentityCodec {
|
|
67
|
+
/** The fixed scope for every knowledge entity. */
|
|
68
|
+
public static readonly scope: MemoryScopeKey = 'knowledge' as MemoryScopeKey;
|
|
69
|
+
|
|
70
|
+
/** {@inheritDoc IIdentityCodec.encode} */
|
|
71
|
+
public encode(entityId: EntityId): Result<IIdentityCodecResult> {
|
|
72
|
+
return assertPortableFilenameStem(entityId).onSuccess((idStem) =>
|
|
73
|
+
succeed({ scope: KnowledgeIdentityCodec.scope, idStem, isVersioned: false })
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** {@inheritDoc IIdentityCodec.decode} */
|
|
78
|
+
public decode(scope: MemoryScopeKey, encodedStem: string): Result<EntityId> {
|
|
79
|
+
if (scope !== KnowledgeIdentityCodec.scope) {
|
|
80
|
+
return fail(
|
|
81
|
+
`knowledge codec: scope '${scope}' does not match expected scope '${KnowledgeIdentityCodec.scope}'`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return assertPortableFilenameStem(encodedStem).onSuccess((stem) => Convert.entityId.convert(stem));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** {@inheritDoc IIdentityCodec.verifyRoundTrip} */
|
|
88
|
+
public verifyRoundTrip(scope: MemoryScopeKey, stem: string): Result<true> {
|
|
89
|
+
return this.decode(scope, stem)
|
|
90
|
+
.onSuccess((entityId) => this.encode(entityId))
|
|
91
|
+
.onSuccess((encoded) => {
|
|
92
|
+
/* c8 ignore start -- defensive: for the identity (knowledge) codec, encode(decode(stem)).idStem always equals stem when both succeed; the guard exists for the non-identity codecs (LTM/MTM, Phase C) that reuse this contract */
|
|
93
|
+
if (encoded.idStem !== stem) {
|
|
94
|
+
return fail(
|
|
95
|
+
`knowledge codec: round-trip mismatch for stem '${stem}' (re-encoded to '${encoded.idStem}')`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
/* c8 ignore stop */
|
|
99
|
+
return succeed(true);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Identity codec for the long-term-memory (LTM) kind family. An LTM entity is
|
|
106
|
+
* keyed by its `conversationId`, used verbatim as the filename stem under the
|
|
107
|
+
* flat `conversations` scope.
|
|
108
|
+
*
|
|
109
|
+
* @remarks
|
|
110
|
+
* - `encode`: scope = `conversations`, idStem = `conversationId`, `isVersioned = false`.
|
|
111
|
+
* - `decode`: brands the stem back to an {@link EntityId}.
|
|
112
|
+
* - Escaping: the `conversationId` must match the POSIX portable filename set.
|
|
113
|
+
* - Layout: `vault/conversations/<conversationId>.md`.
|
|
114
|
+
*
|
|
115
|
+
* An LTM file (`conversations/<id>.md`) and the MTM subtree
|
|
116
|
+
* (`conversations/<id>/turn-N.md`) coexist — a file and a same-named directory
|
|
117
|
+
* are independent on every supported FileTree backend.
|
|
118
|
+
* @public
|
|
119
|
+
*/
|
|
120
|
+
export class LtmIdentityCodec implements IIdentityCodec {
|
|
121
|
+
/** The fixed scope for every LTM entity. */
|
|
122
|
+
public static readonly scope: MemoryScopeKey = 'conversations' as MemoryScopeKey;
|
|
123
|
+
|
|
124
|
+
/** {@inheritDoc IIdentityCodec.encode} */
|
|
125
|
+
public encode(entityId: EntityId): Result<IIdentityCodecResult> {
|
|
126
|
+
return assertPortableFilenameStem(entityId).onSuccess((idStem) =>
|
|
127
|
+
succeed({ scope: LtmIdentityCodec.scope, idStem, isVersioned: false })
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** {@inheritDoc IIdentityCodec.decode} */
|
|
132
|
+
public decode(scope: MemoryScopeKey, encodedStem: string): Result<EntityId> {
|
|
133
|
+
if (scope !== LtmIdentityCodec.scope) {
|
|
134
|
+
return fail(`LTM codec: scope '${scope}' does not match expected scope '${LtmIdentityCodec.scope}'`);
|
|
135
|
+
}
|
|
136
|
+
return assertPortableFilenameStem(encodedStem).onSuccess((stem) => Convert.entityId.convert(stem));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** {@inheritDoc IIdentityCodec.verifyRoundTrip} */
|
|
140
|
+
public verifyRoundTrip(scope: MemoryScopeKey, stem: string): Result<true> {
|
|
141
|
+
return this.decode(scope, stem)
|
|
142
|
+
.onSuccess((entityId) => this.encode(entityId))
|
|
143
|
+
.onSuccess((encoded) => {
|
|
144
|
+
/* c8 ignore start -- defensive: LTM encode(decode(stem)).idStem always equals stem when both succeed (identity mapping); guard preserves the contract for direct callers */
|
|
145
|
+
if (encoded.idStem !== stem) {
|
|
146
|
+
return fail(
|
|
147
|
+
`LTM codec: round-trip mismatch for stem '${stem}' (re-encoded to '${encoded.idStem}')`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
/* c8 ignore stop */
|
|
151
|
+
return succeed(true);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Identity codec for the medium-term-memory (MTM) kind family. An MTM entity is
|
|
158
|
+
* keyed by the colon-composite `<conversationId>:<turnIndex>` and maps into a
|
|
159
|
+
* per-conversation subtree.
|
|
160
|
+
*
|
|
161
|
+
* @remarks
|
|
162
|
+
* - `encode`: splits the entity id on `:`; scope =
|
|
163
|
+
* `conversations/<conversationId>` (multi-segment), idStem = `turn-<turnIndex>`,
|
|
164
|
+
* `isVersioned = false`.
|
|
165
|
+
* - `decode`: reverses scope `conversations/<id>` + stem `turn-<N>` to the
|
|
166
|
+
* composite `<id>:<N>`.
|
|
167
|
+
* - Escaping: `conversationId` must match the POSIX portable filename set (so it
|
|
168
|
+
* contains no `/` or `:`); `turnIndex` must be a non-negative integer string,
|
|
169
|
+
* preserved verbatim so the round-trip is exact.
|
|
170
|
+
* - Layout: `vault/conversations/<conversationId>/turn-<N>.md`. The `/` in the
|
|
171
|
+
* scope is handled by the store's multi-segment scope resolver.
|
|
172
|
+
*
|
|
173
|
+
* **Verbatim turn index (caller-canonicalization note).** The turn index is
|
|
174
|
+
* preserved exactly — `conv-1:7` and `conv-1:007` are DISTINCT entities mapping
|
|
175
|
+
* to distinct files (`turn-7.md` / `turn-007.md`). Verbatim preservation is what
|
|
176
|
+
* makes the round-trip exact, but it means a caller that formats turn indices
|
|
177
|
+
* inconsistently (some zero-padded, some not) will silently mint separate
|
|
178
|
+
* entities. Callers should canonicalize to one form (e.g. no leading zeros).
|
|
179
|
+
* @public
|
|
180
|
+
*/
|
|
181
|
+
export class MtmIdentityCodec implements IIdentityCodec {
|
|
182
|
+
/** The top-level scope segment under which every MTM subtree lives. */
|
|
183
|
+
public static readonly rootScopeSegment: string = 'conversations';
|
|
184
|
+
/** The fixed filename-stem prefix for a turn record. */
|
|
185
|
+
public static readonly turnStemPrefix: string = 'turn-';
|
|
186
|
+
|
|
187
|
+
/** A non-negative integer string (the turn index), preserved verbatim. */
|
|
188
|
+
private static readonly _turnIndexRe: RegExp = /^\d+$/;
|
|
189
|
+
|
|
190
|
+
/** {@inheritDoc IIdentityCodec.encode} */
|
|
191
|
+
public encode(entityId: EntityId): Result<IIdentityCodecResult> {
|
|
192
|
+
const parts: string[] = entityId.split(':');
|
|
193
|
+
if (parts.length !== 2) {
|
|
194
|
+
return fail(`MTM codec: entity id '${entityId}' must be a '<conversationId>:<turnIndex>' composite`);
|
|
195
|
+
}
|
|
196
|
+
const [conversationId, turnIndex] = parts;
|
|
197
|
+
return assertPortableFilenameStem(conversationId)
|
|
198
|
+
.withErrorFormat((msg) => `MTM codec: conversationId '${conversationId}': ${msg}`)
|
|
199
|
+
.onSuccess(() => MtmIdentityCodec._assertTurnIndex(turnIndex))
|
|
200
|
+
.onSuccess(() =>
|
|
201
|
+
succeed({
|
|
202
|
+
scope: `${MtmIdentityCodec.rootScopeSegment}/${conversationId}` as MemoryScopeKey,
|
|
203
|
+
idStem: `${MtmIdentityCodec.turnStemPrefix}${turnIndex}`,
|
|
204
|
+
isVersioned: false
|
|
205
|
+
})
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** {@inheritDoc IIdentityCodec.decode} */
|
|
210
|
+
public decode(scope: MemoryScopeKey, encodedStem: string): Result<EntityId> {
|
|
211
|
+
return MtmIdentityCodec._conversationIdFromScope(scope).onSuccess((conversationId) =>
|
|
212
|
+
MtmIdentityCodec._turnIndexFromStem(encodedStem).onSuccess((turnIndex) =>
|
|
213
|
+
Convert.entityId.convert(`${conversationId}:${turnIndex}`)
|
|
214
|
+
)
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** {@inheritDoc IIdentityCodec.verifyRoundTrip} */
|
|
219
|
+
public verifyRoundTrip(scope: MemoryScopeKey, stem: string): Result<true> {
|
|
220
|
+
return this.decode(scope, stem)
|
|
221
|
+
.onSuccess((entityId) => this.encode(entityId))
|
|
222
|
+
.onSuccess((encoded) => {
|
|
223
|
+
/* c8 ignore start -- defensive: MTM encode(decode(scope, stem)) reproduces the same scope+stem whenever both succeed (no normalization); guard preserves the contract for direct callers */
|
|
224
|
+
if (encoded.idStem !== stem || encoded.scope !== scope) {
|
|
225
|
+
return fail(
|
|
226
|
+
`MTM codec: round-trip mismatch for scope '${scope}' stem '${stem}' (re-encoded to scope '${encoded.scope}' stem '${encoded.idStem}')`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
/* c8 ignore stop */
|
|
230
|
+
return succeed(true);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Validate and extract the `conversationId` from a `conversations/<id>` scope. */
|
|
235
|
+
private static _conversationIdFromScope(scope: MemoryScopeKey): Result<string> {
|
|
236
|
+
const segments: string[] = scope.split('/');
|
|
237
|
+
if (segments.length !== 2 || segments[0] !== MtmIdentityCodec.rootScopeSegment) {
|
|
238
|
+
return fail(
|
|
239
|
+
`MTM codec: scope '${scope}' must be '${MtmIdentityCodec.rootScopeSegment}/<conversationId>'`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
const conversationId: string = segments[1];
|
|
243
|
+
return assertPortableFilenameStem(conversationId)
|
|
244
|
+
.withErrorFormat((msg) => `MTM codec: conversationId '${conversationId}': ${msg}`)
|
|
245
|
+
.onSuccess(() => succeed(conversationId));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Validate and extract the turn-index digits from a `turn-<N>` stem. */
|
|
249
|
+
private static _turnIndexFromStem(stem: string): Result<string> {
|
|
250
|
+
if (!stem.startsWith(MtmIdentityCodec.turnStemPrefix)) {
|
|
251
|
+
return fail(`MTM codec: stem '${stem}' must begin with '${MtmIdentityCodec.turnStemPrefix}'`);
|
|
252
|
+
}
|
|
253
|
+
return MtmIdentityCodec._assertTurnIndex(stem.slice(MtmIdentityCodec.turnStemPrefix.length));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Assert a string is a non-negative integer (the turn index). */
|
|
257
|
+
private static _assertTurnIndex(turnIndex: string): Result<string> {
|
|
258
|
+
if (!MtmIdentityCodec._turnIndexRe.test(turnIndex)) {
|
|
259
|
+
return fail(`MTM codec: turnIndex '${turnIndex}' must be a non-negative integer`);
|
|
260
|
+
}
|
|
261
|
+
return succeed(turnIndex);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2026 Erik Fortune
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Brand, Converter, Converters, Result, fail, succeed } from '@fgv/ts-utils';
|
|
7
|
+
import { assertPortableFilenameStem } from './filenameSafety';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Stable file-stem identifier for a memory record. Equals the codec-produced
|
|
11
|
+
* `idStem` and the on-disk filename stem (`verifyFilenameId` enforces the
|
|
12
|
+
* round-trip).
|
|
13
|
+
* @public
|
|
14
|
+
*/
|
|
15
|
+
export type MemoryId = Brand<string, 'MemoryId'>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Consumer-supplied domain key. The stable entity identity across versions;
|
|
19
|
+
* the package never mints identity. Equals {@link MemoryId} for non-temporal
|
|
20
|
+
* kinds.
|
|
21
|
+
* @public
|
|
22
|
+
*/
|
|
23
|
+
export type EntityId = Brand<string, 'EntityId'>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Open-vocabulary record classifier. Consumers register one body
|
|
27
|
+
* {@link https://www.npmjs.com/package/@fgv/ts-utils | Converter} per kind.
|
|
28
|
+
* @public
|
|
29
|
+
*/
|
|
30
|
+
export type Kind = Brand<string, 'Kind'>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Open-vocabulary tag label.
|
|
34
|
+
* @public
|
|
35
|
+
*/
|
|
36
|
+
export type Tag = Brand<string, 'Tag'>;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Scope path segment. May be multi-segment (`/`-separated) for kinds whose
|
|
40
|
+
* codec maps an entity into a sub-tree (e.g. MTM: `conversations/<id>`). The
|
|
41
|
+
* codec — not this brand — owns filename-safe escaping, so the converter
|
|
42
|
+
* validates only the non-empty/length/whitespace hygiene shared by every
|
|
43
|
+
* brand.
|
|
44
|
+
* @public
|
|
45
|
+
*/
|
|
46
|
+
export type MemoryScopeKey = Brand<string, 'MemoryScopeKey'>;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Open-vocabulary link-relation type for an attributed {@link IEdge}.
|
|
50
|
+
* @public
|
|
51
|
+
*/
|
|
52
|
+
export type LinkType = Brand<string, 'LinkType'>;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Maximum length of any branded identifier in the library. Picked to be
|
|
56
|
+
* larger than any reasonable id a consumer would supply while still bounded
|
|
57
|
+
* to defuse adversarial inputs (e.g. multi-megabyte ids used as map keys).
|
|
58
|
+
*/
|
|
59
|
+
const MAX_BRAND_LENGTH: number = 256;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Builds a `Converter` for one of the branded id scalars in this library.
|
|
63
|
+
* All brands share the same hygiene: non-empty, length-capped, and free of
|
|
64
|
+
* leading/trailing whitespace. Filename-safety and single-segment
|
|
65
|
+
* constraints are intentionally NOT enforced here — the per-kind
|
|
66
|
+
* {@link IIdentityCodec} owns escaping so that multi-segment scope keys
|
|
67
|
+
* (`conversations/<id>`) and composite entity ids (`<id>:<turn>`) validate
|
|
68
|
+
* cleanly.
|
|
69
|
+
*
|
|
70
|
+
* Not exported — the per-brand `Convert.<brand>` constants are the public
|
|
71
|
+
* surface.
|
|
72
|
+
*/
|
|
73
|
+
function brandedIdConverter<T extends string>(brand: string): Converter<T> {
|
|
74
|
+
return Converters.string.map((from: string): Result<T> => {
|
|
75
|
+
if (from.length === 0) {
|
|
76
|
+
return fail(`${brand}: must be a non-empty string`);
|
|
77
|
+
}
|
|
78
|
+
if (from.length > MAX_BRAND_LENGTH) {
|
|
79
|
+
return fail(`${brand}: exceeds maximum length ${MAX_BRAND_LENGTH} (got ${from.length})`);
|
|
80
|
+
}
|
|
81
|
+
if (from !== from.trim()) {
|
|
82
|
+
return fail(`${brand}: must not have leading or trailing whitespace`);
|
|
83
|
+
}
|
|
84
|
+
return succeed(from as unknown as T);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Converters for the branded identifier scalars. Each validates an `unknown`
|
|
90
|
+
* value into the corresponding brand, enforcing the shared hygiene
|
|
91
|
+
* (non-empty, length-capped, trimmed).
|
|
92
|
+
* @public
|
|
93
|
+
*/
|
|
94
|
+
export const Convert: {
|
|
95
|
+
readonly memoryId: Converter<MemoryId>;
|
|
96
|
+
readonly entityId: Converter<EntityId>;
|
|
97
|
+
readonly kind: Converter<Kind>;
|
|
98
|
+
readonly tag: Converter<Tag>;
|
|
99
|
+
readonly scopeKey: Converter<MemoryScopeKey>;
|
|
100
|
+
readonly linkType: Converter<LinkType>;
|
|
101
|
+
} = {
|
|
102
|
+
/**
|
|
103
|
+
* Validates an `unknown` value as a {@link MemoryId}. Unlike the other
|
|
104
|
+
* brands, `MemoryId` IS the on-disk filename stem (`id === idStem`, enforced
|
|
105
|
+
* by the store's `verifyFilenameId`), so it additionally enforces the
|
|
106
|
+
* portable-filename-stem contract — rejecting path-unsafe ids (`a/b`,
|
|
107
|
+
* `conv-1:7`) at convert time rather than letting them surface as a file-path
|
|
108
|
+
* failure later. (`EntityId` stays relaxed: a domain key may legitimately be
|
|
109
|
+
* a composite like `conv-1:7`; the codec maps it to a safe stem.)
|
|
110
|
+
*/
|
|
111
|
+
memoryId: brandedIdConverter<MemoryId>('MemoryId').map((id) =>
|
|
112
|
+
assertPortableFilenameStem(id).onSuccess(() => succeed(id))
|
|
113
|
+
),
|
|
114
|
+
/** Validates an `unknown` value as an {@link EntityId}. */
|
|
115
|
+
entityId: brandedIdConverter<EntityId>('EntityId'),
|
|
116
|
+
/** Validates an `unknown` value as a {@link Kind}. */
|
|
117
|
+
kind: brandedIdConverter<Kind>('Kind'),
|
|
118
|
+
/** Validates an `unknown` value as a {@link Tag}. */
|
|
119
|
+
tag: brandedIdConverter<Tag>('Tag'),
|
|
120
|
+
/** Validates an `unknown` value as a {@link MemoryScopeKey}. */
|
|
121
|
+
scopeKey: brandedIdConverter<MemoryScopeKey>('MemoryScopeKey'),
|
|
122
|
+
/** Validates an `unknown` value as a {@link LinkType}. */
|
|
123
|
+
linkType: brandedIdConverter<LinkType>('LinkType')
|
|
124
|
+
} as const;
|