Memory Signal Ingestion — Usage Guide
The memory layer ships a signal ingestion pipeline that turns raw inbound documents (emails, meeting notes, chat messages, tickets, transcripts…) into entities + facts, without ambiguity over who is who.
This guide covers the recipes. For the underlying API, see MEMORY_API.md; for predicate vocabulary, see MEMORY_PREDICATES.md.
Table of contents
- Why signal ingestion
- Architecture
- Quick start — plain text
- Quick start — email
- Building a custom adapter
- Custom extractor (swap the LLM)
- Participant seeds — semantics + caveats
- Pre-resolved labels in the prompt
- Known entities vs pre-resolved labels
- Handling the result
- Common pitfalls
Why signal ingestion
Left to its own devices, the LLM will hallucinate identities. "Anton emailed Sarah" — which Anton? If your app just pumps the email body through an extraction prompt, the LLM might emit a mention like { surface: "Anton", type: "person" } with no identifier. The memory layer then can't disambiguate against existing Anton Antich <anton@everworker.ai> in the graph.
Email headers already contain the answer: from: anton@everworker.ai. The signal pipeline leverages that:
- Deterministic seeding. An adapter extracts participants from metadata (headers, attendee lists, DB joins). Each participant has strong identifiers (email, Slack ID, domain).
- Pre-binding. The memory layer resolves each seed via
upsertEntityBySurfaceBEFORE the LLM runs. These entity IDs are locked to local labelsm1,m2, …. - Locked vocabulary. The extraction prompt renders a "Pre-resolved labels" block instructing the LLM to use those labels verbatim in facts and NOT redeclare them.
- LLM does less work. It only invents labels for entities genuinely new to the signal body. Identity ambiguity for participants is eliminated upstream.
Architecture
raw signal → SignalSourceAdapter.extract ┐
│
ExtractedSignal { text, participants, sourceDescription }
│
▼
SignalIngestor
│
┌────────────────────────────┴──────────────┐
│ 1. seed phase │
│ each participant → upsertEntityBySurface
│ → PreResolvedBinding[] │
│ │
│ 2. prompt rendering │
│ defaultExtractionPrompt + bindings │
│ │
│ 3. extract phase │
│ IExtractor.extract(prompt) → JSON │
│ │
│ 4. resolve + write │
│ ExtractionResolver.resolveAndIngest │
│ ({ preResolved }) │
└────────────────────────────┬──────────────┘
▼
IngestionResult
Two things are pluggable:
| Plug point | Contract | Stock implementations |
|---|---|---|
SignalSourceAdapter |
extract(raw) => ExtractedSignal |
PlainTextAdapter, EmailSignalAdapter |
IExtractor |
extract(prompt) => Promise<ExtractionOutput> |
ConnectorExtractor (wraps Connector + model) |
Both are exported from oneringai — write your own for anything.
Quick start — plain text
import {
MemorySystem,
InMemoryAdapter,
SignalIngestor,
ConnectorExtractor,
PlainTextAdapter,
} from 'oneringai';
const store = new InMemoryAdapter();
const memory = new MemorySystem({ store });
const ingestor = new SignalIngestor({
memory,
extractor: new ConnectorExtractor({
connector: 'openai-main',
model: 'gpt-5-mini',
}),
adapters: [new PlainTextAdapter()],
});
const result = await ingestor.ingest({
kind: 'text',
raw: { text: 'Jane Smith is the new CTO at Acme Corp.', source: 'onboarding-note' },
sourceSignalId: 'note_42',
scope: { groupId: 'org-1' },
});
console.log(result.entities.map((e) => e.entity.displayName));
// → ['Jane Smith', 'Acme Corp']
The LLM is in charge of inventing mentions here — plain text carries no metadata.
Quick start — email
import { SignalIngestor, ConnectorExtractor, EmailSignalAdapter } from 'oneringai';
const ingestor = new SignalIngestor({
memory,
extractor: new ConnectorExtractor({ connector: 'anthropic-main', model: 'claude-sonnet-4-6' }),
adapters: [new EmailSignalAdapter()], // seeds orgs by default
});
const result = await ingestor.ingest({
kind: 'email',
raw: {
from: { email: 'anton@everworker.ai', name: 'Anton Antich' },
to: [{ email: 'sarah@acme.com', name: 'Sarah Chen' }],
cc: [{ email: 'bob@acme.com' }],
subject: 'Q3 planning',
body: 'Let us lock in the Q3 priorities next week. I will bring the latest roadmap.',
},
sourceSignalId: 'gmail_msg_abc123',
scope: { groupId: 'workspace-1' },
});
What happens under the hood:
EmailSignalAdapter.extractproduces participants: Anton (person,email=anton@everworker.ai), Sarah (person), Bob (person), plus two organization seeds —everworker.aiandacme.com(GMail / Outlook are filtered out automatically).- Each seed is upserted → labels bound as
m1 … m5. - The prompt tells the LLM "m1 is Anton Antich (from), m2 is Sarah Chen (to), … start new labels at m6."
- The LLM writes facts like
{ subject: "m1", predicate: "will_attend", object: "m6" }where m6 is a task it discovers in the body. ExtractionResolverwrites those facts against real entity IDs.
BCC is intentionally dropped — it's not forwarded to the extractor or seeded, so you don't accidentally leak a hidden recipient's identity into the LLM's context.
Building a custom adapter
Adapters are trivial — one method, no I/O. Given a Slack message:
import type { SignalSourceAdapter, ExtractedSignal } from 'oneringai';
interface SlackMessage {
channel: string;
userId: string; // workspace-scoped, e.g. "U07ABCD1234"
userName: string;
text: string;
threadTs?: string;
mentions?: Array<{ userId: string; userName: string }>;
}
export class SlackMessageAdapter implements SignalSourceAdapter<SlackMessage> {
readonly kind = 'slack';
extract(raw: SlackMessage): ExtractedSignal {
return {
signalText: raw.text,
signalSourceDescription: `Slack message in #${raw.channel}`,
participants: [
{
role: 'author',
type: 'person',
identifiers: [{ kind: 'slack_id', value: raw.userId }],
displayName: raw.userName,
},
...(raw.mentions ?? []).map((m) => ({
role: 'mentioned',
type: 'person' as const,
identifiers: [{ kind: 'slack_id', value: m.userId }],
displayName: m.userName,
})),
],
};
}
}
ingestor.registerAdapter(new SlackMessageAdapter());
Guidelines:
- Pure function. No network, no DB. Fetch data ahead of time, pass it via
raw. - Strong identifiers only. Seeds need
kind+valuethat uniquely identify the entity.slack_id,email,domain,github,phoneare good. Display name alone is not an identifier. - Don't over-seed. If a field in metadata isn't someone you'd like in the graph as a first-class entity, don't seed it. A "from: noreply@…" notification sender is spam, not a participant.
- Subject-line handling. For email-like sources, prepend the subject to
signalTextso the LLM sees it. Adapter is responsible for shaping the body the LLM reads.
Custom extractor (swap the LLM)
Any object satisfying the IExtractor contract works:
import type { IExtractor, ExtractionOutput } from 'oneringai';
class ProxyExtractor implements IExtractor {
constructor(private proxyUrl: string, private apiKey: string) {}
async extract(prompt: string): Promise<ExtractionOutput> {
const res = await fetch(`${this.proxyUrl}/extract`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-api-key': this.apiKey },
body: JSON.stringify({ prompt }),
});
return res.json();
}
}
const ingestor = new SignalIngestor({
memory,
extractor: new ProxyExtractor('https://proxy.internal', process.env.PROXY_KEY!),
});
For tests, a mock that returns fixtures is a one-liner:
const extractor: IExtractor = { async extract() { return { mentions: {}, facts: [] }; } };
Participant seeds — semantics + caveats
A ParticipantSeed is a hard commitment — "this identifier IS an entity in the graph." Implications:
- Seeds are upserted before the LLM runs. If the identifier matches an existing entity via your configured
autoResolveThreshold, you get that entity back. Otherwise a new one is created. - Seeds are always
resolved: truein the result. They show up inIngestionResult.entitiesregardless of whether the LLM's output referenced them. - Seeds without strong identifiers are rejected. Seeding on display name alone would bind ambiguously — two people named "Sarah" would collide. The ingestor records a
seed:Nerror inunresolvedand skips the seed instead. - Organization seeding is opt-in (default on).
EmailSignalAdapterseeds non-free domains asorganizationentities. Free providers (Gmail, Outlook, Yahoo, …) are skipped because their domain doesn't identify a single organization. PassseedOrganizations: falseto disable, orfreeEmailProviders: [...]to override the skip list.
Pre-resolved labels in the prompt
When seeds exist, defaultExtractionPrompt renders this block:
## Pre-resolved labels
The following local labels are ALREADY bound to entities in the knowledge graph. Reference them directly in `facts`. DO NOT redeclare them in `mentions`.
- `m1` — from: person "Anton Antich" (email=anton@everworker.ai)
- `m2` — to: person "Sarah Chen" (email=sarah@acme.com)
- `m3` — from-org: organization "everworker.ai" (domain=everworker.ai)
When introducing NEW entities from the signal body, start labels at `m4`.
And the "Output format" section already tells the LLM mentions is a separate object from facts. Between them, well-tuned models respect the contract.
Defensive guarantee: if the LLM ignores the instruction and redeclares m1 in mentions, the ExtractionResolver silently skips the duplicate — the pre-resolved binding wins.
Known entities vs pre-resolved labels
Both appear in the prompt. They behave differently:
knownEntities |
pre-resolved labels | |
|---|---|---|
| Origin | caller-supplied hint (e.g. pre-fetched matches) | derived from signal metadata (adapter → seed phase) |
| Binding to local label | none — LLM must emit a mention to reference it | bound — LLM references the label directly |
| LLM mention required? | yes | no (redeclaration is ignored) |
| Cost | zero | one upsertEntityBySurface per seed |
| Use when | you want the LLM to prefer a surface form | you have deterministic identifiers and want zero ambiguity |
Use knownEntities for thread participants you've seen before but don't want to hard-bind; use pre-resolved seeds (via participants) for header-derived identities.
Handling the result
interface IngestionResult {
entities: IngestionResolvedEntity[]; // all labels (seeds + mentions)
facts: IFact[]; // successfully written facts
mergeCandidates: {...}[]; // near-matches that didn't cross autoResolveThreshold
unresolved: IngestionError[]; // seed:N or fact:N errors
newPredicates: string[]; // unknown-to-registry predicates (drift signal)
}
Patterns:
- Review
mergeCandidatesasynchronously — a human UI or nightly job reconciles near-matches, optionally callingmemory.mergeEntitieson winners. - Monitor
newPredicates— periodically scan ingested batches. Entries usually mean either (a) the LLM is inventing vocabulary → refine your prompt, or (b) you need to register new predicates in your registry. - Log
unresolved—seed:Nmeans your adapter produced a bad seed (fix the adapter).fact:Nmeans the LLM output referenced an undefined label (usually a model blip — safe to retry the signal).
Common pitfalls
- Seeding free-provider domains as orgs.
EmailSignalAdapteralready skips them. If you roll your own adapter, don't seedgmail.com/outlook.comas organizations — thousands of unrelated entities would collapse into one. - Passing display-name-only seeds. Rejected by design. Upsert via
memory.upsertEntityBySurfaceseparately first, then pass the resolved entity asknownEntitiesif you want the LLM to prefer it. - Mutating
rawafterextract. Adapters are synchronous and pure; callers should treatrawas data, not state. If you share the samerawobject across threads or retries, it's fine — adapters don't mutate it. - Forgetting
sourceSignalId. It's how you trace facts back to the originating signal later (findFacts({ sourceSignalIds: [...] })). Use a stable ID from your signal store — the library doesn't own signal storage. - Using the default extractor in tests.
ConnectorExtractorruns real LLM calls. For tests, supply a mockIExtractorwith canned fixtures. - Scope mismatches.
ingest({ scope })is the scope facts get written under, AND the scope seeds are resolved under. If a seed's existing entity is scoped more broadly than the caller (e.g. global), it's visible and will be reused; if scoped more narrowly, a new entity will be created in the caller's scope. Be deliberate about scope propagation.