{"_id":"@automacene/liminal-memory","_rev":"2-ff4828d26897fe7a7838c55e55dcc5ea","name":"@automacene/liminal-memory","dist-tags":{"latest":"1.0.1"},"versions":{"1.0.0":{"name":"@automacene/liminal-memory","version":"1.0.0","keywords":["memory","retrieval","bm25","search","llm","context","nodes"],"author":{"url":"https://github.com/Automacene","name":"Automacene"},"license":"Apache-2.0","_id":"@automacene/liminal-memory@1.0.0","maintainers":[{"name":"codie","email":"petersen.codie@gmail.com"}],"contributors":[{"name":"Codie Petersen"},{"name":"Joshua Torgerson"},{"name":"Asteres Technologies LLC"}],"homepage":"https://github.com/Automacene/liminal-memory#readme","bugs":{"url":"https://github.com/Automacene/liminal-memory/issues"},"dist":{"shasum":"4e028bb904aa2e837f762970085162c0f13a77a8","tarball":"https://registry.npmjs.org/@automacene/liminal-memory/-/liminal-memory-1.0.0.tgz","fileCount":22,"integrity":"sha512-7Qq0q4RQ4wLltGIQrpiQtgxeoDRP95V/e722VaJxYykDk0kPXU718fXgL8Y6V/k2uNfbNFUd1UJX6zob3PkqJQ==","signatures":[{"sig":"MEYCIQCx1iH3Ghn2Ya9TCQ5gceup+xgMgjKedCV+eIjF6JDBrgIhAIoWxthmwqv2rQfHKkNBPF9rIdvZMHllUDE06XF5JXdA","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":196516},"main":"dist/liminal-memory.cjs","type":"module","types":"dist/types/index.d.ts","unpkg":"dist/liminal-memory.min.js","module":"src/index.js","browser":"dist/liminal-memory.min.js","engines":{"node":">=18"},"exports":{".":{"types":"./dist/types/index.d.ts","import":"./src/index.js","default":"./src/index.js","require":"./dist/liminal-memory.cjs"},"./package.json":"./package.json"},"gitHead":"5c0f3600de63e44ddfdaac83c08cb392c692ec4f","scripts":{"test":"node --test","build":"npm run build:esm && npm run build:cjs && npm run build:iife && npm run build:types","example":"node examples/chat-session/run.js","test:all":"npm test && npm run test:types","build:cjs":"esbuild src/index.js --bundle --format=cjs --platform=neutral --outfile=dist/liminal-memory.cjs","build:esm":"esbuild src/index.js --bundle --format=esm --outfile=dist/liminal-memory.esm.js","build:iife":"esbuild src/index.js --bundle --format=iife --global-name=Liminal --minify --outfile=dist/liminal-memory.min.js","test:types":"npm run build:types && tsc -p tests/types","build:types":"tsc","verify:pack":"node scripts/verify-package.mjs","test:examples":"node --test \"examples/**/*.test.js\"","prepublishOnly":"npm run test:all && npm run build"},"_npmUser":{"name":"codie","email":"petersen.codie@gmail.com"},"jsdelivr":"dist/liminal-memory.min.js","repository":{"url":"git+https://github.com/Automacene/liminal-memory.git","type":"git"},"_npmVersion":"11.17.0","description":"Deterministic retrieval over a pool of nodes. Bring your own tagging, graph, and content. Zero dependencies, browser and Node.","directories":{},"sideEffects":false,"_nodeVersion":"24.19.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"esbuild":"^0.21.0","typescript":"^5.6.0"},"_npmOperationalInternal":{"tmp":"tmp/liminal-memory_1.0.0_1786329255250_0.14837556626221193","host":"s3://npm-registry-packages-npm-production"}},"1.0.1":{"name":"@automacene/liminal-memory","version":"1.0.1","description":"Deterministic retrieval over a pool of nodes. Bring your own tagging, graph, and content. Zero dependencies, browser and Node.","type":"module","author":{"name":"Automacene","url":"https://github.com/Automacene"},"contributors":[{"name":"Codie Petersen"},{"name":"Joshua Torgerson"},{"name":"Asteres Technologies LLC"}],"license":"Apache-2.0","homepage":"https://github.com/Automacene/liminal-memory#readme","bugs":{"url":"https://github.com/Automacene/liminal-memory/issues"},"repository":{"type":"git","url":"git+https://github.com/Automacene/liminal-memory.git"},"main":"dist/liminal-memory.cjs","module":"src/index.js","unpkg":"dist/liminal-memory.min.js","jsdelivr":"dist/liminal-memory.min.js","browser":"dist/liminal-memory.min.js","sideEffects":false,"exports":{".":{"types":"./dist/types/index.d.ts","import":"./src/index.js","require":"./dist/liminal-memory.cjs","default":"./src/index.js"},"./package.json":"./package.json"},"publishConfig":{"access":"public"},"scripts":{"test":"node --test","test:examples":"node --test \"examples/**/*.test.js\"","test:types":"npm run build:types && tsc -p tests/types","test:all":"npm test && npm run test:types","verify:pack":"node scripts/verify-package.mjs","example":"node examples/chat-session/run.js","build":"npm run build:esm && npm run build:cjs && npm run build:iife && npm run build:types","build:esm":"esbuild src/index.js --bundle --format=esm --outfile=dist/liminal-memory.esm.js","build:cjs":"esbuild src/index.js --bundle --format=cjs --platform=neutral --outfile=dist/liminal-memory.cjs","build:iife":"esbuild src/index.js --bundle --format=iife --global-name=Liminal --minify --outfile=dist/liminal-memory.min.js","build:types":"tsc","prepublishOnly":"npm run test:all && npm run build"},"keywords":["memory","retrieval","bm25","search","llm","context","nodes"],"devDependencies":{"esbuild":"^0.21.0","typescript":"^5.6.0"},"types":"dist/types/index.d.ts","engines":{"node":">=18"},"gitHead":"48a3607cdd8fac454d0861dfbc703c68c83eac2e","_id":"@automacene/liminal-memory@1.0.1","_nodeVersion":"24.19.0","_npmVersion":"11.17.0","dist":{"integrity":"sha512-AkuP/Q9b6WhuVlHTNU6VWytczW92y+3HIf3cwZsPHllOk4mx1V5Uv2o6ZHYaXTLuta/fyNwqximpOFM0mj2+yQ==","shasum":"d00f8d8981374d47081f3a75604c77788ca01de3","tarball":"https://registry.npmjs.org/@automacene/liminal-memory/-/liminal-memory-1.0.1.tgz","fileCount":22,"unpackedSize":196516,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIC1y926pDe4yIGLwarrfsvA9WgCJsU6x8lGx8KKI4te3AiBcWNbF1DOl5fu+BonEXB4qmMzZNaTCZoycrSs++4V07g=="}]},"_npmUser":{"name":"codie","email":"petersen.codie@gmail.com"},"directories":{},"maintainers":[{"name":"codie","email":"petersen.codie@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/liminal-memory_1.0.1_1786329770491_0.7220261309518483"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-10T02:34:15.154Z","modified":"2026-08-10T02:42:50.780Z","1.0.0":"2026-08-10T02:34:15.396Z","1.0.1":"2026-08-10T02:42:50.615Z"},"bugs":{"url":"https://github.com/Automacene/liminal-memory/issues"},"author":{"name":"Automacene","url":"https://github.com/Automacene"},"license":"Apache-2.0","homepage":"https://github.com/Automacene/liminal-memory#readme","keywords":["memory","retrieval","bm25","search","llm","context","nodes"],"repository":{"type":"git","url":"git+https://github.com/Automacene/liminal-memory.git"},"description":"Deterministic retrieval over a pool of nodes. Bring your own tagging, graph, and content. Zero dependencies, browser and Node.","contributors":[{"name":"Codie Petersen"},{"name":"Joshua Torgerson"},{"name":"Asteres Technologies LLC"}],"maintainers":[{"name":"codie","email":"petersen.codie@gmail.com"}],"readme":"<p align=\"center\">\n  <img src=\"assets/banner.svg\" alt=\"Liminal Memory\" width=\"100%\">\n</p>\n\n[![GitHub Repo stars](https://img.shields.io/github/stars/Automacene/liminal-memory?style=flat&color=gold)](https://github.com/Automacene/liminal-memory)\n[![GitHub forks](https://img.shields.io/github/forks/Automacene/liminal-memory?style=flat&color=blue)](https://github.com/Automacene/liminal-memory)\n[![license](https://img.shields.io/badge/license-Apache%202.0-green)](./LICENSE)\n[![zero dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)]()\n[![platform](https://img.shields.io/badge/platform-browser%20%7C%20node.js-blue)]()\n\n## What is Liminal Memory?\n\nA small library for deterministic recall over a pool of nodes.\n\nLanguage models have two problems with long conversations. The obvious one is capacity: the\nwindow fills and older material falls out. The less obvious one is that a bigger window does not\nfix it, because the model still has to locate the relevant part on its own, softly and\nunpredictably.\n\nLiminal Memory takes the finding step out of the model. You keep your content as nodes in memory,\nsearch them with ordinary relevance math, and hand the model only what matched. The same pool and\nthe same query return the same nodes every time, and you can point at the reason any node came\nback.\n\nIt manages the lifecycle of those nodes and nothing else. Tagging, graph traversal, and the\ncontent itself are yours to define, with a working default for each, so the simple case stays\nsimple.\n\n## Install\n\n```bash\nnpm install @automacene/liminal-memory\n```\n\nOr use it with no build step at all:\n\n```html\n<script src=\"https://unpkg.com/@automacene/liminal-memory\"></script>\n<script>\n  const mem = new Liminal.LiminalMemory();\n</script>\n```\n\n## Getting started\n\n```js\nimport { LiminalMemory } from \"@automacene/liminal-memory\";\n\nconst mem = new LiminalMemory();\n\nawait mem.create({ content: \"the quarterly report is due on friday\" });\nawait mem.create({ content: \"lunch with sam on tuesday\" });\n\nconst hits = await mem.search(\"when is the report due\");\n// [{ id: \"main-0f9c...\", content: \"the quarterly report is due on friday\", ... }]\n```\n\n`search` gives you whole nodes, best first. Feed them to your model however you like: this\nlibrary never talks to one.\n\n## Scores and thresholds\n\n`rank` is `search` with the scores kept. They run from 0 to 1, so one threshold works everywhere:\n\n```js\nconst hits = await mem.rank(\"when is the report due\", { minScore: 0.5 });\n// [{ node, score: 0.89, raw: 2.13 }]\n```\n\nRaw BM25 is unbounded, and its size depends on how many words the query had, so a two word query\nand a ten word query sit on different scales and no fixed cutoff can serve both. Each score here\nis divided by the query's achievable weight and then put through a logistic curve, which keeps\nthe gradation where the keep-or-drop decision actually happens. `raw` is still there if you want\nthe unbounded number.\n\nIn the bundled example a genuine match lands around 0.9 and incidental word overlap around 0.25,\nso 0.5 separates them. Calibrate to your own corpus with `inflection` and `slope`:\n\n```js\nimport { BM25, defaults } from \"@automacene/liminal-memory\";\n\nconst mem = new LiminalMemory({\n  engine: () => new BM25({ inflection: 0.7, slope: 10 })  // stricter, more decisive\n});\n```\n\nTo go back to plain unbounded BM25, exactly what any textbook implementation gives you, turn\ncalibration off:\n\n```js\nconst mem = new LiminalMemory({ engine: () => new BM25({ calibrated: false }) });\n```\n\n`score` is then the raw figure and `minScore` compares against that scale. Ordering is identical\neither way, since the curve is monotonic, so this only changes what the numbers look like.\n\nOne tradeoff worth knowing about calibration: for a single word query every returned node\ncontains that word, so its rarity is identical across them and cancels out. Rarity still shapes\nranking across a multi word query, where matching the unusual word carries far more weight than\nmatching the common one. Turning calibration off keeps rarity in the number.\n\n## The node\n\nEvery node has the same six fields. Three of them are yours to fill with anything.\n\n```js\n{\n  id: \"main-0f9c8b7a-...\",   // unique across every pool\n  pool: \"main\",              // which pool holds it\n  content: \"...\",            // yours: a string, an object, a chunk, anything\n  tags: { keywords: [...] }, // yours: whatever your tagger produces\n  graph: { to: [], from: [] },  // yours: whatever your graph algorithm stores\n  metadata: { createdAt: 0, updatedAt: 0 }  // ours, plus anything you add\n}\n```\n\n`content` is the only required part, and it can be any type:\n\n```js\nawait mem.create({ content: { user: \"where is it\", assistant: \"on the desk\" } });\nawait mem.create({ content: \"a plain string\" });\nawait mem.create({ content: null });  // structural: reachable by graph, never by search\n```\n\nPass an `id` to name a node yourself. Reusing one throws rather than overwriting.\n\n```js\nawait mem.create({ id: \"system-prompt\", content: \"you are...\" });\n```\n\n## Pools\n\nDifferent kinds of node belong in different pools, because relevance scoring depends on\ncorpus-wide statistics. Mixing long conversation turns with short tool descriptions distorts the\nranking of both.\n\n```js\nawait mem.pool(\"chat\").create({ content: \"do you remember the report\" });\nawait mem.pool(\"docs\").create({ content: \"report template v2\" });\n\nawait mem.pool(\"docs\").search(\"report\");  // only ever sees the docs pool\n```\n\nIds are unique across every pool, so `mem.get(id)` finds a node wherever it lives and a graph\nedge can point anywhere.\n\nScores from two pools are not comparable, since each is relative to its own pool's statistics. To\nmerge results, normalize each side first, usually by dividing by that pool's top score.\n\n## Working memory\n\nThe pool is meant to hold what fits in memory. When you want older nodes out, `evict` hands them\nto you on the way:\n\n```js\nconst mem = new LiminalMemory({\n  onEvict: async nodes => db.save(nodes)   // persist however you like\n});\n\nawait mem.pool().evictOldest(100);\nawait mem.pool().evict(node => node.metadata.createdAt < cutoff);\n```\n\n`evict` waits for your hook before dropping anything. `remove` forgets without telling anyone.\n\n## The graph\n\nEdges are optional and never affect ranking. Search returns the same nodes whether or not any\nedges exist.\n\nPass `from` to name the node asking the question. It gets left out of its own results, and\neverything recalled gets linked back to it:\n\n```js\nconst asking = await mem.create({ content: \"do you remember the report\" });\nconst hits = await mem.search(\"report\", { from: asking.id });\n\nmem.neighbors(asking.id);\n// [{ id: \"main-...\", observedAt: 1737000000000, direction: \"to\" }]\n```\n\nThat association is free, since those nodes are already being walked to return them.\n\nAn edge carries `observedAt`, the last time the connection was seen. Seeing it again moves the\ntime forward. An edge created without one never decays, which is what you want for fixed\nstructure such as one document chunk following the next:\n\n```js\nmem.link(chunkA, chunkB);          // permanent\nmem.link(chunkA, chunkB, Date.now());  // decays unless seen again\n```\n\nDecay is off by default. Turn it on per pool:\n\n```js\nimport { decayGraph } from \"@automacene/liminal-memory\";\n\nconst mem = new LiminalMemory({ graph: decayGraph({ decayMs: 7 * 24 * 60 * 60 * 1000 }) });\n```\n\nDecay is lazy: expired edges are dropped from nodes that something touches, not on a timer.\n\n## Bring your own\n\nThree pieces are swappable. Each has a default that works.\n\nA **tagger** turns content into terms. `forNode` writes the tags bucket, `forQuery` turns a query\ninto terms, `termsOf` reads a bucket back out.\n\n```js\nconst mem = new LiminalMemory({\n  tagger: {\n    forNode: async node => ({ embedding: await embed(node.content) }),\n    termsOf: tags => tags.embedding ?? [],\n    forQuery: async query => embed(query)\n  }\n});\n```\n\nAn **engine** ranks ids against terms, with `add`, `remove`, `search`, and `clear`. Swap it\nalongside the tagger, since an engine only understands the terms its tagger produces. Pass a\nfactory when you use more than one pool, because an engine holds one pool's index.\n\nA **graph** handles edges, with `link`, `sweep`, and `neighbors`.\n\nAnything that can call one of your hooks returns a promise, so `create`, `update`, `evict`,\n`search`, and `rank` are async. Pure reads like `get`, `list`, and `size` are not.\n\n## Saving and loading\n\n```js\nconst snapshot = JSON.stringify(mem);\nmem.load(JSON.parse(snapshot));\n```\n\nIds, timestamps, tags, and edges all come back exactly as they were. A restored pool is\nsearchable with no rebuild step, since indexing happens lazily on the first query.\n\n## API\n\n**Container**: `pool(name)`, `pools()`, `hasPool`, `dropPool`, `get`, `has`, `size`, `link`,\n`neighbors`, `toJSON`, `load`, `clear`, plus `create`, `createMany`, `update`, `list`, `search`,\nand `rank` as shorthand for the default pool.\n\n**Pool**: `create`, `createMany`, `update`, `evict`, `evictOldest`, `remove`, `clear`, `search`,\n`rank`, `link`, `neighbors`, `get`, `has`, `list`, `ids`, `size`, `toJSON`, `load`.\n\n**Also exported**: `Pool`, `BM25`, `keywordTagger`, `decayGraph`, `extractKeywords`,\n`flattenToText`, `stem`, `createNode`, `patchNode`, `uuid`, `generateId`, `STOPWORDS`, and\n`defaults`.\n\n## License\n\nApache 2.0\n","readmeFilename":"README.md"}