@aman_asmuei/aman-core
v0.3.0
Published
Shared substrate for the aman ecosystem — Scope, Storage<T> interface, MarkdownFileStorage and DatabaseStorage backends, AsyncLocalStorage propagation, paths, and migration helpers.
Maintainers
Readme
@aman_asmuei/aman-core
The shared substrate for the aman ecosystem.
Multi-tenant Scope, generic Storage<T>, and AsyncLocalStorage propagation —
the foundation for building MCP-native AI companions that remember every user
separately, without threading scope through every function signature.
Install · Quick start · Concepts · API reference · Architecture · The aman ecosystem
What it is
aman-core is the foundation layer of the aman engine. It provides three things,
and only three things:
Scope— a string convention for multi-tenant addressingStorage<T>— a generic interface for layer libraries to implement, plus two ready-to-use backendswithScope()—AsyncLocalStoragepropagation so layer code reads scope implicitly
That's it. No business logic. No LLM clients. No MCP servers. No databases.
It is intentionally tiny, focused, and stable — every other aman layer
(acore-core, arules-core, future aflow-core, etc.) sits on top of it.
Why it exists
The aman ecosystem is built on a single architectural bet:
One engine, three frontends.
The same engine code should serve a developer in Claude Code, a CLI session in their terminal, and thousands of Telegram users in production — with complete state isolation between them, and without any layer library needing to know which one it's running in.
That bet is impossible without a coherent, propagated, multi-tenant identity
system. aman-core is that identity system. Every memory, every rule,
every identity record across the aman ecosystem is keyed by a Scope, and
every layer call automatically picks up the active scope from
AsyncLocalStorage instead of threading it through every function
signature.
The result: a memory you store via the CLI shows up in Claude Code. A rule
you write for dev:plugin doesn't bleed into dev:agent. A Telegram user
at tg:12345 and another at tg:67890 get complete state isolation
even when their requests interleave on the same server. Same code path,
different scope, no leakage.
Install
npm install @aman_asmuei/aman-coreaman-core has zero runtime dependencies by design. It uses Node's
built-in node:async_hooks, node:fs, node:path, and node:os. The only
optional dependency is better-sqlite3 (loaded lazily, only if you use
DatabaseStorage or run the legacy migration helper).
Quick start
import {
withScope,
getCurrentScope,
parseScope,
formatScope,
MarkdownFileStorage,
DatabaseStorage,
type Storage,
} from "@aman_asmuei/aman-core";
// 1. Hosts wrap their per-session entry points in withScope.
// Inside, layer code reads the scope implicitly.
await withScope("tg:user-12345", async () => {
// Anywhere in this async tree — even inside libraries you import —
// calls to getCurrentScope() return "tg:user-12345"
const scope = getCurrentScope(); // "tg:user-12345"
// ... your layer libraries do their thing here
});
// 2. Two parallel sessions don't bleed across each other
await Promise.all([
withScope("tg:alice", async () => {
/* Alice's data only */
}),
withScope("tg:bob", async () => {
/* Bob's data only */
}),
]);
// 3. Layer libraries pick a Storage<T> backend by scope prefix
const identityStorage = new MarkdownFileStorage<Identity>({
root: `${process.env.HOME}/.acore`,
filename: "core.md",
serialize: (i) => i.content,
deserialize: (raw) => ({ content: raw }),
});
await identityStorage.put("dev:default", { content: "# Aman\n..." });
const identity = await identityStorage.get("dev:default");
// → reads ~/.acore/dev/default/core.md
await identityStorage.put("tg:user-12345", { content: "..." });
// → writes ~/.acore/tg/user-12345/core.md (different scope, different file)That's the whole package, in 30 seconds.
Concepts
Scope — a colon-delimited string
A Scope is a string identifying who and where in the ecosystem.
The format is intentionally simple:
<frontend>:<id>[:<sub>...]| Scope | Tenant | Context | Used by |
|----------------------------|--------------|----------------------|----------------------------------|
| dev:default | local dev | default | acore CLI, single-user fallback |
| dev:agent | local dev | aman-agent runtime | aman-agent CLI sessions |
| dev:plugin | local dev | Claude Code plugin | aman-plugin / aman-mcp |
| dev:cli | local dev | generic CLI | one-off scripts |
| tg:12345 | Telegram 12345 | (unset) | aman-tg per-user data |
| agent:jiran | (none) | jiran agent persona | shared agent personality records |
| tg:12345:agent:jiran | TG 12345 | jiran-for-this-user | per-user agent customization |
Why a string and not a struct? Three reasons:
- Backward compatibility.
aman-tgalready usestg:${telegramId}in production. A string format means zero migration on day one. - Wire-format stability. Strings serialize through MCP request metadata, HTTP headers, and database columns without any conversion.
- Simplicity. Two segments handle 99% of cases. The N-segment form handles the rest. No nested-object validation, no schema versioning.
If you need the components, parse it:
parseScope("tg:12345:agent:jiran");
// → {
// frontend: "tg",
// id: "12345",
// parts: ["tg", "12345", "agent", "jiran"],
// raw: "tg:12345:agent:jiran"
// }
formatScope({ frontend: "tg", id: "12345", sub: ["agent", "jiran"] });
// → "tg:12345:agent:jiran"Legacy strings (from before this convention) are normalized automatically:
normalizeLegacyScope("global"); // → "dev:default"
normalizeLegacyScope("myproject"); // → "dev:myproject"
normalizeLegacyScope("tg:12345"); // → "tg:12345" (already canonical)
normalizeLegacyScope(null); // → "dev:default"withScope — AsyncLocalStorage propagation
The killer feature. Hosts wrap their per-session entry points once, and every layer call inside reads the scope implicitly — no parameter threading.
import { withScope, getCurrentScope } from "@aman_asmuei/aman-core";
// In aman-plugin (Claude Code host):
await withScope("dev:plugin", async () => {
// Every call inside here sees scope = "dev:plugin"
await amem.recall("what do i know about pnpm");
await acore.getIdentity();
await arules.checkAction("rm -rf /");
});
// In aman-tg backend (Telegram bot):
bot.on("message", async (ctx) => {
const scope = `tg:${ctx.from.id}`;
await withScope(scope, async () => {
// Jiran sees ONLY this user's memories, identity, and rules
const reply = await jiran.chat(ctx.message.text);
await ctx.reply(reply);
});
});Scope propagates correctly across:
awaitboundaries (Promise.all,setTimeout, callbacks)- Nested
withScope()blocks (inner overrides outer, outer restores after) - Concurrent sessions (two
withScope()calls in parallel never bleed)
If you call getCurrentScope() outside any withScope block, it throws.
Use getCurrentScopeOr(fallback) if you want a default instead.
Storage<T> — the generic interface
Every layer library implements its records via this interface, parameterized by its own record type:
interface Storage<T> {
get(scope: Scope): Promise<T | null>;
put(scope: Scope, value: T): Promise<void>;
patch(scope: Scope, partial: Partial<T>): Promise<void>;
delete(scope: Scope): Promise<void>;
listScopes(): Promise<Scope[]>;
}Two production-ready backends ship with aman-core:
| Backend | Best for | Where it persists |
|-------------------------|-----------------------------------|---------------------------------------------------|
| MarkdownFileStorage<T> | Dev-side (dev:*) — human-edited | {root}/{scopeToPath(scope)}/{filename} on disk |
| DatabaseStorage<T> | Server / multi-tenant — programmatic | SQLite (or Postgres later) table keyed by scope |
Both implement the same Storage<T> interface. Layer libraries pick at runtime:
function getStorageForScope(scope: string): Storage<Identity> {
return parseScope(scope).frontend === "dev"
? markdownStorage // human-editable
: databaseStorage; // multi-tenant
}That's the whole multi-tenant story. Pick by prefix, store by scope.
API reference
Scope helpers
| Symbol | Type | Purpose |
|--------------------------------|-------------|--------------------------------------------------|
| Scope | type alias | = string — colon-delimited, e.g. tg:12345 |
| ParsedScope | interface | {frontend, id, parts, raw} |
| parseScope(scope) | function | Parse a scope into its components |
| formatScope({frontend, id}) | function | Build a scope from components |
| normalizeLegacyScope(s) | function | Convert pre-tenancy strings to canonical form |
AsyncLocalStorage propagation
| Symbol | Returns | Purpose |
|--------------------------------|-------------|--------------------------------------------------|
| withScope(scope, fn) | T | Run fn with scope active in the async tree |
| getCurrentScope() | Scope | Read active scope; throws if none |
| getCurrentScopeOr(fallback) | Scope | Read active scope or return fallback |
| hasActiveScope() | boolean | True if a withScope block is currently active |
Storage<T> backends
| Symbol | Purpose |
|-------------------------------|----------------------------------------------------------------|
| Storage<T> | The generic interface — get/put/patch/delete/listScopes |
| StorageWithLocation | Optional tag interface for backends that expose .location() |
| MarkdownFileStorage<T> | One file per scope, human-editable, git-versionable |
| DatabaseStorage<T> | One row per scope in a SQLite table; lazy better-sqlite3 |
Path helpers
| Symbol | Returns | Purpose |
|--------------------------------|-------------|--------------------------------------------------|
| getEngineDbPath() | string | ~/.aman/engine.db (or $AMAN_ENGINE_DB) |
| getAmanHome() | string | ~/.aman (or $AMAN_HOME) |
| ensureDir(path) | void | Idempotent recursive mkdir |
| scopeToPath(scope) | string | tg:12345:agent:jiran → tg/12345/agent/jiran |
Migration
| Symbol | Purpose |
|--------------------------------|----------------------------------------------------------------|
| migrateLegacyAmemDb(opts?) | One-time copy of ~/.amem/memory.db → ~/.aman/engine.db with legacy scopes rewritten |
The migration is idempotent and never deletes the legacy file.
Architecture
┌─────────────────────────────────────┐
│ aman engine v1 — 4 layer libs │
│ │
│ acore-core arules-core │
│ amem-core (future layers) │
│ │ │ │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ aman-core │ ← YOU │
│ │ (this package) │ ARE │
│ │ │ HERE │
│ │ Scope │ │
│ │ Storage<T> │ │
│ │ withScope │ │
│ │ paths + migrate │ │
│ └─────────────────────┘ │
└─────────────────────────────────────┘
▲
│
┌─────────────────────┼─────────────────────┐
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ aman-plugin │ │ aman-agent │ │ aman-tg │
│ Claude Code │ │ CLI runtime │ │ Telegram │
│ │ │ │ │ super-app │
│ scope= │ │ scope= │ │ scope= │
│ dev:plugin │ │ dev:agent │ │ tg:userId │
└──────────────┘ └──────────────┘ └──────────────┘aman-core is the foundation. The four layer libraries (acore-core,
arules-core, amem-core, future ones) consume it to build their
multi-tenant features. The three frontends (Claude Code via aman-plugin,
CLI via aman-agent, Telegram via aman-tg) all run on the same engine
through this single substrate.
A bug fix in aman-core propagates to every layer and every frontend
simultaneously. That's the win condition.
What this is NOT
To stay tiny and stable, aman-core deliberately does not provide:
- A database. It defines the storage interface; concrete backends for layers' record types live in those layers (or use the two backends shipped here).
- An LLM client. That's the runtime's job (
aman-agent, theaman-tgbackend). - An MCP server. That's
aman-mcp. - Identity, rules, or memory. Those are separate layer libraries
(
acore-core,arules-core,amem-core). - A configuration system. Layers configure themselves via env vars and constructor options.
If you're looking for the "full aman experience," install the layer libraries and a frontend. This package is the substrate they share.
Quality signals
- 74 unit tests, all passing, across 5 test files:
scope.test.ts— 23 tests covering parse/format/normalize and AsyncLocalStorage propagation including parallel-no-bleedpaths.test.ts— 11 tests covering env overrides andscopeToPathsanitizationmigrate.test.ts— 5 integration tests with a real SQLite databasemarkdown-file-storage.test.ts— 16 tests covering get/put/patch/delete/listScopes and isolationdatabase-storage.test.ts— 19 tests covering the same plus table-name SQL-injection rejection
tsc --noEmitclean withstrict,noUnusedLocals,noUnusedParameters,noImplicitReturns- ESM only, Node ≥18, TypeScript declarations + sourcemaps included
- Zero runtime dependencies. Optional
better-sqlite3loaded lazily.
The aman ecosystem
aman-core is one of several packages in the aman AI companion ecosystem:
| Layer | Role | |------------------------------------------------------------------------|-----------------------------------------------------| | @aman_asmuei/aman-core | Substrate — Scope, Storage, withScope (this) | | @aman_asmuei/acore-core | Identity layer — multi-tenant Identity records | | @aman_asmuei/arules-core | Guardrails layer — rule parsing and runtime checks | | @aman_asmuei/amem-core | Memory layer — semantic recall, embeddings | | @aman_asmuei/aman-mcp | MCP server aggregating all layers for any host | | @aman_asmuei/aman-agent | Standalone CLI runtime, multi-LLM, scope-aware | | aman-plugin | Claude Code plugin (hooks + skills + MCP installer) | | @aman_asmuei/aman | Umbrella installer — one command for the ecosystem |
License
MIT © Aman Asmuei
