Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion GRAPH_SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ init Cypher, example queries) see
| `Technology` ✅ 🤔 | A specific tool, language, library, or platform | Overlaps with `Concept`. Currently a categorized Concept; the live graph uses both. Convention going forward: prefer `Technology` for concrete tech (React, Neo4j); `Concept` for abstractions (LLM-wiki-pattern, MVC). |
| `Decision` ✅ | A choice made or position taken | Often emitted by the dream extractor from "we decided…" / "I chose…" statements. |
| `Reasoning` ✅ | The why behind a Decision | Pairs with Decision via `LED_TO`; lighter than Decision itself. |
| `Preference` ✅ | A stated user preference or rule | Person `PREFERS` Preference. Has `domain`, `key`, `value` properties. |
| `Preference` ✅ | A stated user preference | Person `PREFERS` Preference. Has `domain`, `key`, `value` properties. Use `subtype: 'rule'` for permanent/hard-rule preferences ("never X", "always Y") — these are pinned at `confidence: 1.0` and exempt from decay and pruning. They change only on explicit user statement. |
| `Event` ✅ | A point-in-time happening | Meeting, milestone, release, incident |
| `Fact` ✅ | A standalone piece of knowledge | Description-heavy. Best paired with `ABOUT` to whatever it's a fact *about*. |
| `Artifact` ✅ | A created/authored output (doc, file, transcript, gist) | Subtype of Object — distinct because authorship matters. Pair with `AUTHORED` / `PRODUCED`. |
Expand Down
1 change: 1 addition & 0 deletions prompts/dream-nightly.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ Focus on **knowledge**, not conversation mechanics. Skip "let me read that file"
- **Projects** discussed, worked on, or referenced
- **Technologies/Concepts** used, evaluated, or discussed
- **Preferences** stated or confirmed ("I prefer X", "always use Y")
- **Rule subtype**: when the user states a hard, permanent constraint ("never X", "always Y" — no expected sunset), capture as a Preference with `subtype: 'rule'`, `confidence: 1.0`, and `PREFERS` edge weight `1.0`. Rules are exempt from decay/prune and only change on explicit user statement. Reserve for permanent constraints, not soft preferences.
- **Decisions** made with reasoning ("we decided to X because Y")
- **Facts** about infrastructure, processes, or configuration
- **Events** — meetings, deployments, incidents, milestones
Expand Down
1 change: 1 addition & 0 deletions prompts/graph-capture.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Walk the conversation (or the slice indicated by `--since-message` / `--topic`)
- **Projects** worked on, evaluated, or referenced with meaningful context
- **Technologies / Concepts** the user used, evaluated, decided about, or expressed a preference toward
- **Preferences** explicitly stated ("I prefer X", "always use Y") or strongly implied through repeated choice
- **Rule subtype**: when the user states a hard, permanent constraint with no expected sunset ("never X", "always Y", "X must never appear in Z"), capture as a Preference with `subtype: 'rule'`, `confidence: 1.0`, and a `PREFERS` edge weight of `1.0`. Rules are exempt from decay and pruning and only change on explicit user statement. Reserve this for genuinely permanent constraints, not soft preferences.
- **Decisions** made with reasoning ("we decided X because Y") — both explicit and clear inferred decisions
- **Facts** about infrastructure, processes, configuration, or the user's environment
- **Events** — meetings, deployments, incidents, milestones with dates or outcomes
Expand Down
29 changes: 24 additions & 5 deletions src/shared/neo4j-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -881,6 +881,7 @@ export class Neo4jClient {
`
MATCH (n:\`${type}\` {tenant_id: $tenantId})
WHERE n.last_seen < datetime() - duration('P1D')
AND (n.subtype IS NULL OR n.subtype <> 'rule')
RETURN count(n) AS count
`,
{ tenantId },
Expand All @@ -893,9 +894,12 @@ export class Neo4jClient {
`
MATCH (n:\`${type}\` {tenant_id: $tenantId})
WHERE n.last_seen < datetime() - duration('P1D')
AND (n.subtype IS NULL OR n.subtype <> 'rule')
// duration.inDays() forces an all-days representation; using .days
// on the normalized duration.between() would drop the months
// component (30 days back → "1 month + 0 days" → 0-day decay).
// Nodes with subtype='rule' (permanent preferences) are exempt
// from decay entirely — they only change on explicit user statement.
WITH n, n.confidence * ($rate ^ duration.inDays(n.last_seen, datetime()).days) AS new_conf
SET n.confidence = CASE WHEN new_conf < 0.01 THEN 0.01 ELSE new_conf END
RETURN count(n) AS decayed
Expand All @@ -905,12 +909,16 @@ export class Neo4jClient {
totalNodesDecayed += Number(rows[0]?.["decayed"] ?? 0);
}

// Decay edges (both endpoints must be in tenant)
// Decay edges (both endpoints must be in tenant). Edges touching a
// subtype='rule' node on either side are exempt — otherwise a rule's
// anchor PREFERS edge would slowly decay and orphan the rule.
const edgeRows = await this.run(
`
MATCH (a:Entity {tenant_id: $tenantId})-[r]->(b:Entity {tenant_id: $tenantId})
WHERE r.last_confirmed < datetime() - duration('P1D')
AND r.weight IS NOT NULL
AND (a.subtype IS NULL OR a.subtype <> 'rule')
AND (b.subtype IS NULL OR b.subtype <> 'rule')
WITH r, r.weight * ($rate ^ duration.inDays(r.last_confirmed, datetime()).days) AS new_weight
SET r.weight = CASE WHEN new_weight < 0.01 THEN 0.01 ELSE new_weight END
RETURN count(r) AS decayed
Expand All @@ -920,11 +928,14 @@ export class Neo4jClient {
totalEdgesDecayed = Number(edgeRows[0]?.["decayed"] ?? 0);
}

// Count nodes flagged for pruning (tenant-scoped)
// Count nodes flagged for pruning (tenant-scoped). Rule-subtype nodes
// are pinned at confidence 1.0 so they'd never cross the threshold,
// but we filter them explicitly for clarity and defense-in-depth.
const pruneRows = await this.run(
`
MATCH (n:Entity {tenant_id: $tenantId})
WHERE n.confidence < $threshold
AND (n.subtype IS NULL OR n.subtype <> 'rule')
OPTIONAL MATCH (n)-[r]-(other:Entity {tenant_id: $tenantId})
WITH n, max(r.weight) AS max_edge_weight
WHERE max_edge_weight IS NULL OR max_edge_weight < $edgeThreshold
Expand Down Expand Up @@ -1230,11 +1241,13 @@ export class Neo4jClient {
const includeOrphans = options.include_orphans ?? true;
const maxAgeDays = options.max_age_days ?? config.decay.prune_orphan_days;

// Find pruneable nodes (tenant-scoped)
// Find pruneable nodes (tenant-scoped). Rule-subtype nodes are
// permanent and exempt from pruning regardless of confidence.
const nodeRows = await this.run(
`
MATCH (n:Entity {tenant_id: $tenantId})
WHERE n.confidence < $nodeThreshold
AND (n.subtype IS NULL OR n.subtype <> 'rule')
OPTIONAL MATCH (n)-[r]-(other:Entity {tenant_id: $tenantId})
WITH n, labels(n) AS labels, max(r.weight) AS maxEdge
WHERE maxEdge IS NULL OR maxEdge < $edgeThreshold
Expand All @@ -1245,7 +1258,9 @@ export class Neo4jClient {
{ tenantId, nodeThreshold, edgeThreshold },
);

// Find orphans if requested
// Find orphans if requested. Rule-subtype nodes are exempt even if
// they become disconnected — a stranded rule should be reconnected,
// not deleted.
let orphanRows: Row[] = [];
if (includeOrphans) {
orphanRows = await this.run(
Expand All @@ -1254,6 +1269,7 @@ export class Neo4jClient {
WHERE NOT (n)-[]-()
AND n.last_seen < datetime() - duration({days: $maxAgeDays})
AND n.confidence >= $nodeThreshold
AND (n.subtype IS NULL OR n.subtype <> 'rule')
RETURN n.id AS id, n.name AS name,
[l IN labels(n) WHERE l <> 'Entity'][0] AS type,
n.confidence AS confidence
Expand All @@ -1262,11 +1278,14 @@ export class Neo4jClient {
);
}

// Find pruneable edges (both endpoints in tenant)
// Find pruneable edges (both endpoints in tenant). Edges touching a
// rule-subtype node on either side are exempt.
const edgeRows = await this.run(
`
MATCH (a:Entity {tenant_id: $tenantId})-[r]->(b:Entity {tenant_id: $tenantId})
WHERE r.weight < $edgeThreshold
AND (a.subtype IS NULL OR a.subtype <> 'rule')
AND (b.subtype IS NULL OR b.subtype <> 'rule')
RETURN a.id AS fromId, b.id AS toId, type(r) AS relType, r.weight AS weight
`,
{ tenantId, edgeThreshold },
Expand Down