phileas-memory 0.1.0__py3-none-any.whl
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.
- phileas/__init__.py +3 -0
- phileas/api.py +209 -0
- phileas/assets/skills/phileas/SKILL.md +165 -0
- phileas/cli/__init__.py +87 -0
- phileas/cli/commands.py +938 -0
- phileas/cli/formatter.py +93 -0
- phileas/cli/wizard.py +515 -0
- phileas/config.py +224 -0
- phileas/daemon.py +845 -0
- phileas/db.py +939 -0
- phileas/engine.py +2052 -0
- phileas/fusion.py +155 -0
- phileas/graph.py +2326 -0
- phileas/graph_proxy.py +278 -0
- phileas/health.py +248 -0
- phileas/ingest.py +69 -0
- phileas/logging.py +123 -0
- phileas/mcp_auth.py +346 -0
- phileas/models.py +72 -0
- phileas/recall_format.py +167 -0
- phileas/reranker.py +40 -0
- phileas/scoring.py +181 -0
- phileas/server.py +991 -0
- phileas/standout.py +231 -0
- phileas/stats/__init__.py +1 -0
- phileas/stats/cli.py +400 -0
- phileas/stats/graph_probe.py +79 -0
- phileas/stats/queries.py +536 -0
- phileas/stats/render.py +43 -0
- phileas/stats/time.py +77 -0
- phileas/stats/usage.py +141 -0
- phileas/stats/writer.py +250 -0
- phileas/stopwords.py +155 -0
- phileas/sync.py +222 -0
- phileas/sync_stream.py +102 -0
- phileas/systemd.py +169 -0
- phileas/tool_runner.py +367 -0
- phileas/vector.py +175 -0
- phileas_memory-0.1.0.dist-info/METADATA +81 -0
- phileas_memory-0.1.0.dist-info/RECORD +42 -0
- phileas_memory-0.1.0.dist-info/WHEEL +4 -0
- phileas_memory-0.1.0.dist-info/entry_points.txt +2 -0
phileas/__init__.py
ADDED
phileas/api.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""HTTP transport for the Phileas daemon — the outward read contract.
|
|
2
|
+
|
|
3
|
+
The daemon (``daemon.py``) owns process lifecycle: it forks, loads and holds the
|
|
4
|
+
models and the KuzuDB write lock, runs the background workers, and handles
|
|
5
|
+
signals. This module owns only the wire — it wraps the already-loaded engine in
|
|
6
|
+
a FastAPI app and serves it. Splitting lifecycle from transport lets the daemon
|
|
7
|
+
grow or swap its HTTP surface without touching how the process lives and dies.
|
|
8
|
+
|
|
9
|
+
Why FastAPI, single worker, in-process:
|
|
10
|
+
- The engine is a singleton holding embeddings, the reranker, and the graph
|
|
11
|
+
write lock, so the daemon must stay one process. This runs under one uvicorn
|
|
12
|
+
worker — never ``--workers N``, which would fork the models and race the
|
|
13
|
+
lock. Concurrency comes from threads, not processes.
|
|
14
|
+
- Sync endpoints (the CPU-bound recall / db calls) are offloaded to anyio's
|
|
15
|
+
worker threadpool; we cap it to mirror the daemon's bounded HTTP pool so a
|
|
16
|
+
request burst can't fan out unbounded threads and pin glibc arenas.
|
|
17
|
+
- Pydantic response models are the stable read contract: FastAPI emits OpenAPI
|
|
18
|
+
from them, so ``web/src/lib/types.ts`` can be generated from the schema
|
|
19
|
+
rather than hand-mirrored against the raw ``memory_items`` columns.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
from contextlib import asynccontextmanager
|
|
28
|
+
from typing import TYPE_CHECKING
|
|
29
|
+
|
|
30
|
+
import anyio
|
|
31
|
+
import uvicorn
|
|
32
|
+
from fastapi import Depends, FastAPI, Header, HTTPException, Request, Response
|
|
33
|
+
from pydantic import BaseModel
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from phileas.engine import MemoryEngine
|
|
37
|
+
|
|
38
|
+
# Mirror the daemon's bounded HTTP pool (daemon.py:ThreadedHTTPServer, 4 workers):
|
|
39
|
+
# cap the threads FastAPI uses to run sync endpoints so bursts stay bounded.
|
|
40
|
+
_SYNC_THREAD_LIMIT = 4
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# -- Response models = the read contract -------------------------------------
|
|
44
|
+
# These mirror db._row_to_web_dict / web/src/lib/types.ts:MemoryItem. Keep them
|
|
45
|
+
# as the single source of truth and generate the TS types from /openapi.json.
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MemoryItem(BaseModel):
|
|
49
|
+
id: str
|
|
50
|
+
summary: str
|
|
51
|
+
memory_type: str
|
|
52
|
+
status: str
|
|
53
|
+
access_count: int
|
|
54
|
+
storage_strength: float
|
|
55
|
+
reinforcement_count: int
|
|
56
|
+
last_reinforced: str | None
|
|
57
|
+
daily_ref: str | None
|
|
58
|
+
created_at: str | None
|
|
59
|
+
updated_at: str | None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class DayCount(BaseModel):
|
|
63
|
+
day: str
|
|
64
|
+
count: int
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class IngestionHealth(BaseModel):
|
|
68
|
+
events_received_1h: int
|
|
69
|
+
events_received_24h: int
|
|
70
|
+
events_total: int
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class IngestionEvent(BaseModel):
|
|
74
|
+
id: str
|
|
75
|
+
received_at: str
|
|
76
|
+
text_preview: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class IdList(BaseModel):
|
|
80
|
+
ids: list[str] = []
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# -- Auth --------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _make_auth(expected_token: str | None):
|
|
87
|
+
"""Bearer-token gate. No token configured (the loopback default) → open.
|
|
88
|
+
|
|
89
|
+
When the daemon is bound to the box, set ``PHILEAS_API_TOKEN`` there and
|
|
90
|
+
every guarded route then requires ``Authorization: Bearer <token>``.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
async def guard(authorization: str | None = Header(default=None)) -> None:
|
|
94
|
+
if not expected_token:
|
|
95
|
+
return
|
|
96
|
+
scheme = "Bearer "
|
|
97
|
+
if not authorization or not authorization.startswith(scheme):
|
|
98
|
+
raise HTTPException(status_code=401, detail="missing bearer token")
|
|
99
|
+
if authorization[len(scheme) :] != expected_token:
|
|
100
|
+
raise HTTPException(status_code=403, detail="bad token")
|
|
101
|
+
|
|
102
|
+
return guard
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# -- App factory -------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@asynccontextmanager
|
|
109
|
+
async def _lifespan(app: FastAPI):
|
|
110
|
+
# Bound the threadpool that offloads sync endpoints (runs inside the loop).
|
|
111
|
+
anyio.to_thread.current_default_thread_limiter().total_tokens = _SYNC_THREAD_LIMIT
|
|
112
|
+
yield
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def create_app(engine: MemoryEngine, dispatch=None) -> FastAPI:
|
|
116
|
+
"""Build the FastAPI app around an already-loaded engine.
|
|
117
|
+
|
|
118
|
+
The daemon loads the engine (models, write lock) and hands it in; this
|
|
119
|
+
module never constructs storage itself.
|
|
120
|
+
|
|
121
|
+
``dispatch(method, params)`` is the migration bridge: when supplied, a
|
|
122
|
+
catch-all ``POST /`` speaks the legacy JSON-RPC every CLI/MCP client and
|
|
123
|
+
``daemon.call`` already use, so typed routes below can grow one group at a
|
|
124
|
+
time without a flag-day. Retire the bridge once nothing posts to ``/``.
|
|
125
|
+
"""
|
|
126
|
+
auth = Depends(_make_auth(os.environ.get("PHILEAS_API_TOKEN")))
|
|
127
|
+
app = FastAPI(title="Phileas read API", version="1", lifespan=_lifespan)
|
|
128
|
+
db = engine.db
|
|
129
|
+
|
|
130
|
+
@app.get("/health")
|
|
131
|
+
def health() -> dict:
|
|
132
|
+
# Unauthenticated on purpose: the liveness probe for push-health.
|
|
133
|
+
return {"ok": True, "pid": os.getpid()}
|
|
134
|
+
|
|
135
|
+
if dispatch is not None:
|
|
136
|
+
|
|
137
|
+
@app.post("/")
|
|
138
|
+
async def jsonrpc(request: Request) -> Response:
|
|
139
|
+
raw = await request.body()
|
|
140
|
+
body = json.loads(raw) if raw else {}
|
|
141
|
+
method = body.get("method", "")
|
|
142
|
+
params = body.get("params", {})
|
|
143
|
+
try:
|
|
144
|
+
# Offload the sync engine call to the bounded threadpool so a
|
|
145
|
+
# slow recall never blocks the event loop.
|
|
146
|
+
result = await anyio.to_thread.run_sync(dispatch, method, params)
|
|
147
|
+
payload, status = {"ok": True, "result": result}, 200
|
|
148
|
+
except Exception as exc:
|
|
149
|
+
payload, status = {"ok": False, "error": str(exc)}, 500
|
|
150
|
+
# default=str matches the legacy server's datetime handling.
|
|
151
|
+
return Response(json.dumps(payload, default=str), media_type="application/json", status_code=status)
|
|
152
|
+
|
|
153
|
+
# -- Memories read group (the direct-SQLite paths web must stop using) ---
|
|
154
|
+
|
|
155
|
+
@app.get("/memories/day", response_model=list[MemoryItem], dependencies=[auth])
|
|
156
|
+
def memories_for_day(start: str, end: str):
|
|
157
|
+
return db.web_memories_for_day(start, end)
|
|
158
|
+
|
|
159
|
+
@app.get("/memories/search", response_model=list[MemoryItem], dependencies=[auth])
|
|
160
|
+
def memories_search(q: str = "", limit: int = 100):
|
|
161
|
+
return db.web_search(q, limit)
|
|
162
|
+
|
|
163
|
+
@app.post("/memories/by-ids", response_model=list[MemoryItem], dependencies=[auth])
|
|
164
|
+
def memories_by_ids(body: IdList):
|
|
165
|
+
return db.web_memories_by_ids(body.ids)
|
|
166
|
+
|
|
167
|
+
@app.get("/memories/days", response_model=list[DayCount], dependencies=[auth])
|
|
168
|
+
def memories_days(limit: int = 60, tz_offset_minutes: int | None = None):
|
|
169
|
+
return db.web_days_with_counts(limit, tz_offset_minutes)
|
|
170
|
+
|
|
171
|
+
# -- Ingestion health + forensics ---------------------------------------
|
|
172
|
+
|
|
173
|
+
@app.get("/ingestion/health", response_model=IngestionHealth, dependencies=[auth])
|
|
174
|
+
def ingestion_health():
|
|
175
|
+
return db.web_ingestion_health()
|
|
176
|
+
|
|
177
|
+
@app.get("/ingestion/events", response_model=list[IngestionEvent], dependencies=[auth])
|
|
178
|
+
def ingestion_events(limit: int = 50):
|
|
179
|
+
return db.web_ingestion_events(limit)
|
|
180
|
+
|
|
181
|
+
@app.get("/ingestion/events/{event_id}", dependencies=[auth])
|
|
182
|
+
def ingestion_event(event_id: str):
|
|
183
|
+
event = db.web_ingestion_event(event_id)
|
|
184
|
+
if event is None:
|
|
185
|
+
raise HTTPException(status_code=404, detail="event not found")
|
|
186
|
+
return event
|
|
187
|
+
|
|
188
|
+
# The remaining _dispatch groups port the same way, each as its own router:
|
|
189
|
+
# graph_read / graph_write → /graph/* (broker the KuzuDB write lock)
|
|
190
|
+
# metrics_* → /metrics/* (phileas.stats.queries)
|
|
191
|
+
# recall-family tools → /tools/* (tool_runner.run, byte-identical)
|
|
192
|
+
# memorize / forget / … → POST writes; arm the sync pusher after success
|
|
193
|
+
return app
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# -- Serving (driven by the daemon's lifecycle) ------------------------------
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def make_server(app: FastAPI, *, log_level: str = "warning") -> uvicorn.Server:
|
|
200
|
+
"""A single-worker uvicorn server. The daemon binds the socket and drives
|
|
201
|
+
start/stop, so this never installs its own signal handlers."""
|
|
202
|
+
config = uvicorn.Config(app, log_level=log_level, access_log=False)
|
|
203
|
+
return uvicorn.Server(config)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def serve(server: uvicorn.Server, sockets) -> None:
|
|
207
|
+
"""Blocking run on a pre-bound socket — call from the daemon's server thread.
|
|
208
|
+
Flip ``server.should_exit = True`` to stop it."""
|
|
209
|
+
asyncio.run(server.serve(sockets=sockets))
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: phileas
|
|
3
|
+
description: Phileas long-term companion memory. Recall past context BEFORE answering when the prompt references past work, decisions, named projects, people, dates, or asks "what did we / last time / remember when". Memorize new facts when the user shares personal info, makes decisions, expresses preferences, discusses life events, or makes an explicit memory request.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Phileas — Companion Memory
|
|
7
|
+
|
|
8
|
+
Phileas is the user's centralized memory layer — three databases (SQLite + ChromaDB + KuzuDB) behind an MCP connector that store facts, find them semantically, and connect entities.
|
|
9
|
+
|
|
10
|
+
Tool names below are written bare (`recall_recent`, `about`, `recall`, …). They map to whatever prefix your environment exposes them under — `mcp__phileas__*` in local Claude Code, the connector's prefix on claude.ai. Use the matching name from your tool list.
|
|
11
|
+
|
|
12
|
+
## Recall — load context before answering
|
|
13
|
+
|
|
14
|
+
Recall when the prompt references past work, decisions, people, dates, named projects, or asks anything like "what did I / we", "last time", "remember when", "before we", "you mentioned". Skip recall entirely when the prompt is purely about the code, task, or conversation already in front of you.
|
|
15
|
+
|
|
16
|
+
### Query shape — focused terms, not sentences
|
|
17
|
+
|
|
18
|
+
Phileas reads `recall(query=...)` best as a *focused term phrase* — one concept, 1–4 words. The keyword path OR-matches each token against memory summaries and ranks by coverage: a summary surfaces if it holds *any* token, and ranks higher the more of the query's tokens co-occur in it. A focused phrase floats the memory whose summary carries all its tokens to the top; a verbatim user sentence ("what did the user say about Alex and the Q3 budget") instead drags in filler tokens ("what", "did", "the") that match unrelated memories and dilute the coverage signal — and long natural-language queries score poorly on the semantic path too. So **extract the named entities and concepts from the prompt first**, then issue one tool call per concept and merge the results by `id`: coverage rewards tokens that co-occur, so concepts living in *separate* memories surface far better as separate queries. For *"did Alex bring up the Q3 budget at the planning offsite"*: call `about("Alex")`, `recall("Q3 budget")`, and `recall("planning offsite")` in parallel — not one sentence-shaped `recall()`.
|
|
19
|
+
|
|
20
|
+
### Pick the tool by query shape
|
|
21
|
+
|
|
22
|
+
Route by the shape of the question. Call several in parallel when shapes overlap, then merge results by `id`:
|
|
23
|
+
|
|
24
|
+
- **Time-relative** ("yesterday", "recently", "last week", "last session", "last time we talked") → `recall_recent(days=N)`. Top memories per day, newest first, bounded. Reach for this first when the question has a temporal anchor.
|
|
25
|
+
- **Named entity** in the prompt (person, project, tool) → `about(name=...)`. Pass the bare name without a leading `@`. Returns every memory linked to that entity in the graph — the cheapest, most precise "who is X / what about Y" lookup. Bounded ("+N more" footer when a hub entity is capped).
|
|
26
|
+
- **Explicit date** ("2026-04-14", "Apr 14") → `list_day_memories(date="YYYY-MM-DD")`. Every active memory anchored to that day.
|
|
27
|
+
- **Topic / concept** with no entity or date anchor → `recall(query=<focused term, 1–4 words>)`. Hybrid gather + cross-encoder rerank.
|
|
28
|
+
- **Date range** spanning multiple days → `timeline(start=..., end=...)`.
|
|
29
|
+
- **Wildcard / cross-topic nudge** (no anchor; you want what the task *wouldn't* surface) → `serendipity(n=3)`. Opt-in, not relevance-gated. Pass ids already in context as `exclude_ids`.
|
|
30
|
+
|
|
31
|
+
### Pointers in, hydrate on demand
|
|
32
|
+
|
|
33
|
+
Recall-family tools (`recall`, `recall_recent`, `about`, `timeline`) return cheap **pointers**, not full bodies:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
[a1b2c3d4] [event] 2026-06-07 · Mara bought a cake in Lisbon last night · Mara, Lisbon
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
That line is `[id8] [type] date · summary · entity tags`. The summary is the whole fact — for most prompts the pointers already answer the question, so **don't fan out `recall()` a dozen times hoping for depth**, and don't dump everything. `recall_recent` and `about` are bounded (a heavy day or hub entity shows a cap / `+N more` note) so they can't overflow the context.
|
|
40
|
+
|
|
41
|
+
When you genuinely need more than a pointer, drill in — cheapest to most expensive:
|
|
42
|
+
|
|
43
|
+
- `hydrate(id8)` — the full record of **one** memory: exact timestamps, status/counts, its source turn (the raw it was distilled from), its `thread_id`, and linked entities. The inverse of the pointer trim.
|
|
44
|
+
- `thread(thread_id)` — the conversation a memory came from: its raw turns in order, each with the memories it produced. Get `thread_id` from `hydrate` first. The deepest, most expensive view.
|
|
45
|
+
- `about(name)` — everything tied to an entity (also bounded).
|
|
46
|
+
|
|
47
|
+
Rule of thumb: scan pointers → hydrate the one or two that matter → thread only if you need the surrounding conversation. Each hop up the ladder costs more context, so climb it deliberately.
|
|
48
|
+
|
|
49
|
+
### Use the context
|
|
50
|
+
|
|
51
|
+
Treat recalled memories as background context, not as content to recite. Reference them when answering only if directly relevant. Never lead a response with "Based on my memory…" — work the context in naturally.
|
|
52
|
+
|
|
53
|
+
## Memorize — store new facts
|
|
54
|
+
|
|
55
|
+
Inline `memorize` (and `memorize_batch` for multiple facts from one turn).
|
|
56
|
+
|
|
57
|
+
### Writes are three steps — open a thread, capture the turn, then memorize
|
|
58
|
+
|
|
59
|
+
A memory points back at the raw turn it was distilled from, and that turn belongs to a conversation thread. So:
|
|
60
|
+
|
|
61
|
+
1. `start_thread(client_key="claude_code:<session_id>")` — **once at the start of the conversation.** It returns a `thread_id`; keep it and reuse it for every capture below. Passing the session id as `client_key` makes it resume-safe: after a context compaction or a `--resume`, calling `start_thread` again with the same key continues the same thread instead of splitting the conversation. (No session id to hand? Call it once and just reuse the returned `thread_id`.)
|
|
62
|
+
2. `ingest_text(text=<the verbatim turn>, thread_id=<that thread_id>)` — stores and embeds the raw turn as an *event* in the thread, and returns its `event_id`.
|
|
63
|
+
3. `memorize(summary=..., source_event_id=<that event_id>)` — records the memory, linked to the turn it came from.
|
|
64
|
+
|
|
65
|
+
`source_event_id` is required; a `memorize` that can't name a real event is refused. That link is what lets `thread(thread_id)` replay the conversation behind a memory, and what keeps a memory anchored to the evidence it came from.
|
|
66
|
+
|
|
67
|
+
A thread is only as complete as the turns you capture, and that's your call. Phileas hands you the calls; you decide what a conversation is worth — ingest the turns that carry something to remember under the one `thread_id` so `thread()` reads back as the conversation rather than scattered fragments, and let the rest pass. Some conversations earn a full thread, some a single pinned memory, some nothing at all. When a single turn yields several facts, call `ingest_text` once and reuse its `event_id` across each `memorize` (or as the batch-level `source_event_id` for `memorize_batch`) — don't mint a fresh event per fact from the same turn.
|
|
68
|
+
|
|
69
|
+
`ingest_text` and `start_thread` take a `source_kind` that defaults to `"agent"` — live capture by you, the in-session model. Leave it at the default.
|
|
70
|
+
|
|
71
|
+
### What to save
|
|
72
|
+
|
|
73
|
+
Phileas captures what the code alone and git alone will not preserve. **Archaeology test:** will this still be useful when the code shows only the result and git shows only the diff?
|
|
74
|
+
|
|
75
|
+
- **Personal facts** the user states about themselves, people in their life, or their situation.
|
|
76
|
+
- **Preferences** about tools, workflow, tone, collaboration style.
|
|
77
|
+
- **Decisions** — especially ones with a stated reason ("we're going with X because Y").
|
|
78
|
+
- **Project decision archaeology** — why X over Y, what was rejected, who pushed back, deadline/constraint that forced the call, alternative tried and reverted. The narrative behind the diff.
|
|
79
|
+
- **Events** with a time anchor ("shipped v0.1.0 on Apr 4", "trip to Tokyo next month").
|
|
80
|
+
- **Patterns** observed over time — recurring frustrations, emotional throughlines, habits.
|
|
81
|
+
- **Project state** not derivable from code or git (ownership, blockers, why a design was chosen).
|
|
82
|
+
|
|
83
|
+
### What NOT to save
|
|
84
|
+
|
|
85
|
+
- **Forward-prescriptive conventions** ("always use snake_case", "tests live in `tests/`") — those belong in `CLAUDE.md`, which is the right home for rules. Phileas holds the *backward-narrative* archaeology, not the rulebook.
|
|
86
|
+
- **How the code works** — re-readable from the repo.
|
|
87
|
+
- **Git history, recent commits, who-changed-what** — `git log`/`git blame` are authoritative.
|
|
88
|
+
- **Transient task state** (current in-progress step, conversation context, temp debugging notes).
|
|
89
|
+
- **Anything already in `CLAUDE.md` or the repo's own docs.**
|
|
90
|
+
- **Fix recipes from debugging** — the commit explains the fix; don't mirror it in memory.
|
|
91
|
+
|
|
92
|
+
### Memory types
|
|
93
|
+
|
|
94
|
+
Pass `memory_type` as exactly one of these five — anything else stores but won't match recall's type filter:
|
|
95
|
+
|
|
96
|
+
- `profile` — who the user is: name, identity, core traits.
|
|
97
|
+
- `event` — things that happened: dates, milestones, life events.
|
|
98
|
+
- `knowledge` — facts, skills, stated preferences, and opinions the user holds (the default).
|
|
99
|
+
- `behavior` — recurring patterns and habits: workflows, communication and collaboration style.
|
|
100
|
+
- `reflection` — higher-level inferences across memories (usually generated by `reflect`).
|
|
101
|
+
|
|
102
|
+
Pick the one that best matches how the memory would be recalled later. Emotional throughlines and recurring patterns fold into `behavior` or `reflection` — there is no separate `emotional`, `pattern`, `preference`, or `project` type.
|
|
103
|
+
|
|
104
|
+
### Dedupe before writing
|
|
105
|
+
|
|
106
|
+
Before calling `memorize`, do a quick `recall` on the core entity or topic. If a very similar memory already exists:
|
|
107
|
+
|
|
108
|
+
- **Same fact, same wording** → skip.
|
|
109
|
+
- **Same fact, refined or corrected** → call `update()` on the existing memory_id instead of creating a new one.
|
|
110
|
+
- **Related but distinct angle** → write the new one; use `relate()` to link them.
|
|
111
|
+
|
|
112
|
+
### Summary
|
|
113
|
+
|
|
114
|
+
- `summary` should be one sentence, self-contained — readable without the original turn for context.
|
|
115
|
+
- Pick the right `memory_type` — it seeds the memory's durability (identity-level `profile` starts deeper than a one-off `event`), and durability then grows on its own each time the memory is recalled or reinforced.
|
|
116
|
+
|
|
117
|
+
### Language
|
|
118
|
+
|
|
119
|
+
**Always write `summary` (and the verbatim text you pass to `ingest_text`) in English, even when the source turn is in Vietnamese or mixed language.** Translate the user's words; preserve proper nouns (people, places, projects, @mentions, brand names, and Vietnamese terms with no clean English equivalent — keep those in italics or quotes).
|
|
120
|
+
|
|
121
|
+
*Why:* Phileas embeds with `all-MiniLM-L6-v2`, an English-centric model. Vietnamese-vs-Vietnamese similarity peaks around 0.40–0.49, below the 0.5 recall floor — so non-English memories store cleanly but never surface in recall.
|
|
122
|
+
|
|
123
|
+
*Examples:*
|
|
124
|
+
- Source: "Sếp bảo phải nộp báo cáo trước thứ 6." → Summary: "Boss said the report must be submitted before Friday."
|
|
125
|
+
- Source: "Anh ấy nhắc về *tiền đen* trong ngành." → Summary: "He warned about *tiền đen* (off-the-books money) in the industry." (preserve the term, gloss it once)
|
|
126
|
+
- Don't store: "user mới biết hả" — translate: "User just learned this."
|
|
127
|
+
|
|
128
|
+
### Batching
|
|
129
|
+
|
|
130
|
+
When a single turn yields several distinct memories, prefer `memorize_batch` over N sequential `memorize` calls — it's faster and cheaper.
|
|
131
|
+
|
|
132
|
+
### Entity tagging
|
|
133
|
+
|
|
134
|
+
When calling `memorize` with `entities=[...]`, only tag an entity whose presence a future `about(name=<entity>)` query would find useful. A tag says "this memory is *about* this entity," not "this entity appears in this memory."
|
|
135
|
+
|
|
136
|
+
**The user-entity trap.** Nearly every memory is implicitly authored *by* the user. Tagging `Person:<user>` on every one makes `about('<user>')` return the whole activity log. Only tag the user when the memory is genuinely identity-shaped:
|
|
137
|
+
|
|
138
|
+
- **Tag `Person:<user>`** on `profile`, `behavior`, and `reflection` memories — things that describe who they are, how they act, or inferences about them.
|
|
139
|
+
- **Don't tag `Person:<user>`** on `event` and `knowledge` memories — the user is the implicit narrator; the tag adds noise, not signal.
|
|
140
|
+
|
|
141
|
+
**Other people and entities** (colleagues, partners, projects, tools) can be tagged freely — they're not implicit narrators, so `about(them)` is a useful retrieval primitive.
|
|
142
|
+
|
|
143
|
+
### Disambiguating same-name entities
|
|
144
|
+
|
|
145
|
+
Identity in the graph is an opaque uuid; `name` and `type` are attributes. The linker decides whether a new mention reuses an existing entity or mints a new one. Provide an optional `description` (one short line) on entity records when the name is potentially ambiguous — `Apple` the fruit vs. `Apple` the company, two people both named Alex, etc. Description is written once at entity creation and never overwritten, so it stays a stable disambiguator.
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
{"name": "Apple", "type": "Company", "description": "consumer electronics maker (Tim Cook era)"}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Skip `description` when the name is unambiguous in the user's world (their colleagues, their projects). For multi-type referents the same physical thing may carry — `Acme` is a place AND the company that owns it AND a project name — let the linker collapse them onto one uuid by tagging consistently and the migration script handles legacy splits.
|
|
152
|
+
|
|
153
|
+
### Handle vs. display-name: user-declared aliases
|
|
154
|
+
|
|
155
|
+
Phileas does **not** auto-pair a username-handle with a display name. Name bridges happen only three ways: diacritic/case folding (automatic and safe — `José` ↔ `Jose`), an explicit user-declared `alias`, or `merge_entities` to fold already-split nodes. The linker never guesses handle↔name pairings, because handle stems collide across *distinct* people — e.g. `samwk` (**W**ong, Sam K.) and `samrk` (**R**oss, Sam K.) share the stem `sam`, so an auto-merge would silently fuse two real people. A miss is recoverable; a wrong merge is not.
|
|
156
|
+
|
|
157
|
+
So when the user refers to someone by a bare or partial name that may be ambiguous (or when `about(name)` looks like it's returning only a fragment of a person):
|
|
158
|
+
|
|
159
|
+
1. `find_entities(stem)` — lists every candidate (norm-aware, so `sam` surfaces `samwk`, `samrk`, and `Sam`), with memory counts and descriptions.
|
|
160
|
+
2. If more than one plausible match, **ask the user which one** — do not pick for them.
|
|
161
|
+
3. Persist their answer with `alias(name=<the unambiguous handle>, alias=<what they call them>)` — e.g. the user says "call Wong's one *sam wong*" → `alias(name="samwk", alias="sam wong")`. Afterwards `about("sam wong")` and future mentions resolve to that entity.
|
|
162
|
+
4. If a candidate is an orphaned fragment that genuinely belongs to another (e.g. a 1-memory `Sam` node that is the same person as `samwk`), fold it with `merge_entities` rather than aliasing.
|
|
163
|
+
|
|
164
|
+
The alias is the user's convention, set explicitly — never inferred.
|
|
165
|
+
|
phileas/cli/__init__.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Phileas CLI — Click entry point.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
phileas status
|
|
5
|
+
phileas remember "I like Python"
|
|
6
|
+
phileas recall "what languages"
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from phileas import __version__
|
|
12
|
+
from phileas.cli.commands import (
|
|
13
|
+
about,
|
|
14
|
+
contradictions,
|
|
15
|
+
export_cmd,
|
|
16
|
+
find_entities,
|
|
17
|
+
forget,
|
|
18
|
+
health,
|
|
19
|
+
hydrate,
|
|
20
|
+
ingest,
|
|
21
|
+
init_cmd,
|
|
22
|
+
list_cmd,
|
|
23
|
+
list_day,
|
|
24
|
+
recall,
|
|
25
|
+
recall_recent,
|
|
26
|
+
reflect,
|
|
27
|
+
remember,
|
|
28
|
+
resolve_cmd,
|
|
29
|
+
retry_events,
|
|
30
|
+
scope_cmd,
|
|
31
|
+
scopes,
|
|
32
|
+
serendipity,
|
|
33
|
+
serve,
|
|
34
|
+
show,
|
|
35
|
+
start,
|
|
36
|
+
status,
|
|
37
|
+
stop_cmd,
|
|
38
|
+
sync_export_cmd,
|
|
39
|
+
sync_import_cmd,
|
|
40
|
+
sync_plan_cmd,
|
|
41
|
+
thread,
|
|
42
|
+
timeline,
|
|
43
|
+
update_cmd,
|
|
44
|
+
usage,
|
|
45
|
+
)
|
|
46
|
+
from phileas.stats.cli import stats
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@click.group()
|
|
50
|
+
@click.version_option(version=__version__, prog_name="phileas")
|
|
51
|
+
def app():
|
|
52
|
+
"""Phileas -- persistent memory for AI."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
app.add_command(status)
|
|
56
|
+
app.add_command(health)
|
|
57
|
+
app.add_command(remember)
|
|
58
|
+
app.add_command(recall)
|
|
59
|
+
app.add_command(recall_recent)
|
|
60
|
+
app.add_command(timeline)
|
|
61
|
+
app.add_command(about)
|
|
62
|
+
app.add_command(list_day)
|
|
63
|
+
app.add_command(serendipity)
|
|
64
|
+
app.add_command(hydrate)
|
|
65
|
+
app.add_command(thread)
|
|
66
|
+
app.add_command(find_entities)
|
|
67
|
+
app.add_command(scope_cmd)
|
|
68
|
+
app.add_command(scopes)
|
|
69
|
+
app.add_command(resolve_cmd)
|
|
70
|
+
app.add_command(forget)
|
|
71
|
+
app.add_command(update_cmd)
|
|
72
|
+
app.add_command(list_cmd)
|
|
73
|
+
app.add_command(show)
|
|
74
|
+
app.add_command(ingest)
|
|
75
|
+
app.add_command(reflect)
|
|
76
|
+
app.add_command(contradictions)
|
|
77
|
+
app.add_command(export_cmd)
|
|
78
|
+
app.add_command(serve)
|
|
79
|
+
app.add_command(init_cmd)
|
|
80
|
+
app.add_command(start)
|
|
81
|
+
app.add_command(stop_cmd, "stop")
|
|
82
|
+
app.add_command(usage)
|
|
83
|
+
app.add_command(retry_events)
|
|
84
|
+
app.add_command(sync_export_cmd)
|
|
85
|
+
app.add_command(sync_plan_cmd)
|
|
86
|
+
app.add_command(sync_import_cmd)
|
|
87
|
+
app.add_command(stats)
|