GhostPaw

@ghostpaw/affinity 0.3.0

Who have you been meaning to call?

affinity keeps the people your agent deals with, the promises made to them and the dates that matter in your SQLite file. It works out who is drifting from what happened, so nobody has to keep a list of who to call.

As a library

As a local MCP server

Replay the run Read the source on GitHub

Free and MIT licensed for Node.js 24 or newer, with zero runtime dependencies. The MCP server keeps everything in ~/.ghostpaw/affinity.db on your machine.

A radar scope drawn in thin cyan light with a sweeping wedge, a ghost paw print at its centre and three points, the farthest one violet and drifting outward.

0runtime dependencies

Three people, five calls

Jun is at the top of the radar. One evening out moves him to the bottom.

This is a replay of a real run. We made these calls against affinity 0.3.0 on 2026-10-24 with a fixed clock, and every number below is what came back. Mika knows three people: Ren, an old roommate she called six days ago; Jun, a climbing partner she last saw 27 days ago; and Ada, who she met at a conference 41 days ago. Run the steps in order.

Radar and open items

  • Jun0.347 · drifting
  • Ada0.124 · routine
  • Ren0.000 · routine
  • Open promisesnone
  • Next 60 daysnothing

read.listRadar(db), highest drift first

  1. 01 An evening out with Jun

    Mika records what happened. She does not set a score.

    write.recordInteraction(db, {
      type: 'activity',
      occurredAt: now,
      summary: 'Climbing with Jun, then dinner',
      significance: 6,
      participants: [
        { contactId: mika.id, role: 'actor', directionality: 'mutual' },
        { contactId: jun.id, role: 'recipient', directionality: 'mutual' },
      ],
    });
    // derivedEffects
    [{ affinityDelta: 0.245, trustDelta: 0.025, rankAfter: 2, cadenceAfter: 17 }]
    
    read.listRadar(db).map((r) => r.name)
    // ['Ada', 'Ren', 'Jun']

    Jun goes from the top of the radar to the bottom. The library also stretched his expected cadence from 14 to 17 days.

  2. 02 A promise to Ren

    A commitment is a journal entry with a resolution that starts out open.

    const promise = write.recordCommitment(db, {
      commitmentType: 'promise',
      occurredAt: now,
      summary: 'Send Ren the notes on the offer letter',
      significance: 5,
      participants: [
        { contactId: mika.id, role: 'actor', directionality: 'owner_initiated' },
        { contactId: ren.id, role: 'recipient' },
      ],
    });
    // derivedEffects
    [{ affinityDelta: 0.031, trustDelta: -0.044, rankAfter: 3, cadenceAfter: 12 }]
    
    read.listOpenCommitments(db).length
    // 1

    Ren's trust went down by 0.044 when the promise was recorded. It now shows as open.

  3. 03 Close it as “done”

    This is how an agent would naturally put it.

    write.resolveCommitment(db, promise.primary.id, 'done');
    // AffinityValidationError
    // resolution must be kept, cancelled, or broken

    Refused. A promise is kept, cancelled or broken, and “done” is none of those.

  4. 04 Close it as kept

    write.resolveCommitment(db, promise.primary.id, 'kept');
    // derivedEffects
    []
    
    read.listOpenCommitments(db).length
    // 0

    The promise is closed. Keeping it did not add anything back in this run: Ren's trust stays at 0.669.

  5. 05 Jun's birthday

    A date anchor is a yearly date on a contact.

    write.addDateAnchor(db, {
      target: { kind: 'contact', contactId: jun.id },
      recurrenceKind: 'birthday',
      anchorMonth: 11,
      anchorDay: 12,
      summary: "Jun's birthday",
      significance: 7,
    });
    read.listUpcomingDates(db, { horizonDays: 60 })
    // [{ summary: "Jun's birthday", recurrenceKind: 'birthday', occursOn: '2026-11-12' }]

    It turns up 19 days ahead, with no calendar entry needed.

The radar is refreshed when an event is written, not when you read it. That is why this replay never moves the clock: without a new write, the order stays where the last write left it.

Every write returns derivedEffects, so your agent can say what changed instead of guessing.

What it keeps, and what it works out

Hexagon nodes joined by threads of cyan light of different weights, a bright node at the centre and one thin violet thread fading out.

A relationship is its own record, not a field on a contact card.

Contact
A person, company, team, service, pet, household or group, plus one owner contact for you.
Identity
An email, phone number, URL or account id that points at exactly one contact. Duplicates can be merged or dismissed.
Social link
The relationship itself, with rank, affinity, trust, cadence, a short bond note and a state.
Tie
A structural fact such as works_at, reports_to or kinship.
Journal entry
What happened: an interaction, observation, milestone, transaction or commitment.
Date anchor
A yearly date such as a birthday, anniversary or renewal.
Attribute
Free keys and values for preferences, on contacts and on links.

Rank, trust, drift and the rest are computed from the journal and the ties. None of them is a status flag your code sets by hand.

If a people database makes you uneasy

The usual cost is the client or friend you forgot to follow up with. The usual fear is the opposite: an assistant that keeps a page on everyone you mention, which sounds like a dossier, and recent coverage of exactly that is fair warning.

  • It is a library and a local MCP server. There is no hosted service and nothing leaves the SQLite file unless your code sends it.
  • It stores what you or your agent record. It does not read your mail, messages or contacts on its own.
  • The MCP server's file is ~/.ghostpaw/affinity.db, or wherever GHOSTPAW_AFFINITY_DB or --db points.
  • Twelve tools with JSON Schemas, among them record_event, manage_commitment and review_affinity. Each returns success, no_op, needs_clarification or error.
  • Node.js 24 or newer, because it uses the built-in node:sqlite.

What it costs to add

Runtime dependencies
0
Node.js
24 or newer (node:sqlite)
Size of dist/index.js, gzip
56.1 KB
Import time
about 5.7 ms
Tests
459 passing
Line coverage
95.3%
Agent tools
12

Measured 2026-10-24 on the maintainer's machine with Node.js 24.14.0 against affinity 0.3.0: size of the published dist/index.js at gzip -9, median of 11 cold imports, tests and coverage from the v0.3.0 tag.

Start from the README

import { DatabaseSync } from 'node:sqlite';
import { initAffinityTables, read, write } from '@ghostpaw/affinity';

const db = new DatabaseSync(':memory:');
db.exec('PRAGMA foreign_keys = ON');
initAffinityTables(db);

const { primary: owner } = write.createContact(db, {
  name: 'Mika',
  kind: 'human',
  bootstrapOwner: true,
});
const { primary: ada } = write.createContact(db, { name: 'Ada Lovelace', kind: 'human' });
write.addIdentity(db, ada.id, { type: 'email', value: 'ada@example.com' });

write.recordInteraction(db, {
  type: 'conversation',
  occurredAt: Date.now(),
  summary: 'Coffee together',
  significance: 6,
  participants: [
    { contactId: owner.id, role: 'actor', directionality: 'mutual' },
    { contactId: ada.id, role: 'recipient', directionality: 'mutual' },
  ],
});

const radar = read.listRadar(db);

To run it as an MCP server instead, add npx -y @ghostpaw/affinity mcp to your client. npx -y @ghostpaw/affinity skill prints the Agent Skill that ships in the package.