Type node creation and filter local memory by effective type

- nap_create_node accepts `type`; when omitted (also in nap_bulk and
  agent-node creates) the type is inferred from the folder and sent
  explicitly, so older APIs also store typed plans and decisions.
  Explicit spelling variants are canonicalized; custom types pass as is.
- Local browse, full-text and encrypted-shard searches filter on the
  effective type (canonical spelling, or the folder's type for generic
  notes; `type=note` excludes notes whose folder implies a type); server
  rows are post-filtered the same way. Rows expose `effective_type` when
  it differs from the stored type; discover offers an effective_type
  facet.
- Active-only filters also exclude deployed, released,
  released_verified_production and folded.
Mirrors napseer src/vocab.rs; decision
/decisions/2026-09-29-node-type-status-vocabulary.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
HumbleTurtle
2026-09-29 00:32:36 -03:00
co-authored by Claude Opus 5.5
parent 611a4571f3
commit f201cb358b
4 changed files with 624 additions and 22 deletions
+205 -18
View File
@@ -66,7 +66,7 @@ DISCOVERY_COMPACT_MAX_LIMIT = 10_000
DISCOVERY_FULL_MAX_LIMIT = 10_000
DISCOVERY_SOURCE_PAGE_LIMIT = 10_000
DISCOVERY_CURSOR_GUARD_PAGES = 100
ACTIVE_EXCLUDED_STATUSES = {
ACTIVE_EXCLUDED_LIFECYCLE_STATUSES = {
"completed",
"complete_verified_uncommitted",
"complete_pending_human_e2e",
@@ -75,6 +75,131 @@ ACTIVE_EXCLUDED_STATUSES = {
"archived",
"done",
}
# Node type/status vocabulary. This block mirrors napseer's API vocabulary
# module (src/vocab.rs; decision /decisions/2026-09-29-node-type-status-vocabulary);
# keep the two in step. Stored records are never rewritten: a create without a
# type gets the folder's type, and local filters/rows use the *effective* type.
CANONICAL_NODE_TYPES = frozenset({
"plan",
"decision",
"implementation_note",
"deployment",
"documentation",
"product_spec",
"runbook",
"rule",
"index",
"analysis",
"kanban_card",
"anchor_route",
"note",
})
# Folder prefixes whose generic notes take a specific type. A prefix matches
# the folder itself and anything below it (whole path segments, longest wins).
FOLDER_NODE_TYPES = (
("/plans", "plan"),
("/decisions", "decision"),
("/product/decisions", "decision"),
("/implementation-notes", "implementation_note"),
("/deployments", "deployment"),
("/runbooks", "runbook"),
("/rules", "rule"),
("/indexes", "index"),
("/analysis", "analysis"),
("/product/analysis", "analysis"),
("/documentation", "documentation"),
("/kanban", "kanban_card"),
)
# Release-progress statuses are terminal too. They stay apart from the
# lifecycle set (the API keeps them out of its Kanban lifecycle list) and do
# not demote search ranking; active-only filters exclude both sets.
ACTIVE_EXCLUDED_RELEASE_STATUSES = frozenset({
"deployed",
"released",
"released_verified_production",
"folded",
})
ACTIVE_EXCLUDED_STATUSES = frozenset(ACTIVE_EXCLUDED_LIFECYCLE_STATUSES | ACTIVE_EXCLUDED_RELEASE_STATUSES)
def canonical_node_type(value):
"""Spelling-only canonical form: trimmed, lowercase, '-' and ' ' become '_'."""
return str(value or "").strip().lower().replace("-", "_").replace(" ", "_")
def normalize_type_folder(folder_path):
"""A folder as the server stores it (lowercase slug segments), for type rules."""
segments = []
for segment in str(folder_path or "").split("/"):
segment = re.sub(r"[^a-z0-9._]+", "-", segment.strip().lower()).strip("-")
if segment:
segments.append(segment)
return "/" + "/".join(segments) if segments else None
def folder_node_type(folder_path):
"""The type a folder implies for a node that does not name one, or None."""
folder = str(folder_path or "")
matches = [
(prefix, node_type)
for prefix, node_type in FOLDER_NODE_TYPES
if folder == prefix or folder.startswith(f"{prefix}/")
]
return max(matches, key=lambda match: len(match[0]))[1] if matches else None
def effective_node_type(node_type, folder_path):
"""Canonical stored type, except a generic note resolves from its folder."""
canonical = canonical_node_type(node_type)
if canonical in ("", "note"):
return folder_node_type(folder_path) or "note"
return canonical
def explicit_node_type(value):
"""A caller's type: known spelling variants canonicalize; custom types stay as given."""
canonical = canonical_node_type(value)
return canonical if canonical in CANONICAL_NODE_TYPES else value
def create_node_type(value, folder_path):
"""Type to send on create; None leaves the server default (no folder rule)."""
if value not in (None, ""):
return explicit_node_type(value)
return folder_node_type(normalize_type_folder(folder_path))
def node_effective_type(node):
"""Effective type of a node or row, or None when the row carries no type."""
if node.get("effective_type") not in (None, ""):
return node["effective_type"]
node_type = node.get("type") if "type" in node else node.get("node_type")
if node_type is None:
return None
folder = node.get("folder_path")
if folder is None and node.get("full_path"):
folder = str(node["full_path"]).rsplit("/", 1)[0] or "/"
return effective_node_type(node_type, folder)
def requested_node_types(value):
"""Canonical type filter values (a string, comma list, or list)."""
return unique_preserve_order(
canonical_node_type(item) for item in normalize_string_list(value) if canonical_node_type(item)
)
def apply_type_filter(nodes, value):
"""Keep rows whose effective type was requested. Older servers match the
stored type, so a `note` filter would also return notes whose folder
implies another type; rows without a type are left to the server."""
types = set(requested_node_types(value))
if not types:
return nodes
return [node for node in nodes if node_effective_type(node) in types | {None}]
KNOWN_LIFECYCLE_STATUSES = {
"active",
"archived",
@@ -6162,7 +6287,7 @@ def status_filter_from_args(args):
active_only = as_bool(args.get("active_only"), False)
warnings = []
for status in statuses + excluded:
if status not in KNOWN_LIFECYCLE_STATUSES:
if status not in KNOWN_LIFECYCLE_STATUSES and status not in ACTIVE_EXCLUDED_RELEASE_STATUSES:
warnings.append(f"unknown metadata.status filter: {status}")
return {"status": statuses, "exclude_status": excluded, "active_only": active_only, "warnings": warnings}
@@ -6263,6 +6388,13 @@ def metadata_summary(node):
return {key: metadata[key] for key in unique_preserve_order(keys) if key in metadata}
def reported_effective_type(node):
"""effective_type for a compact row: only when it differs from the stored
type, so correctly typed rows keep their existing compact shape."""
effective = node_effective_type(node)
return effective if effective != node.get("type") else None
def summarize_node(node, view="summary", preview_chars=240):
node_id = node.get("node_id") or node.get("id")
if view == "full":
@@ -6281,6 +6413,7 @@ def summarize_node(node, view="summary", preview_chars=240):
"node_id": node_id,
"full_path": node.get("full_path"),
"type": node.get("type"),
"effective_type": reported_effective_type(node),
"status": status,
"date": node_date(node),
"created_at": node.get("created_at"),
@@ -6296,6 +6429,7 @@ def summarize_node(node, view="summary", preview_chars=240):
"project_id": node.get("project_id"),
"full_path": node.get("full_path"),
"type": node.get("type"),
"effective_type": reported_effective_type(node),
"name": node.get("name"),
"folder_path": node.get("folder_path"),
"tags": node.get("tags") or [],
@@ -6816,15 +6950,37 @@ def ensure_sqlite_column(conn, table, column, declaration):
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {declaration}")
def register_index_functions(conn):
"""SQL helpers so local type filters use the one Python vocabulary."""
for name, arity, function in (
("nap_effective_type", 2, effective_node_type),
):
try:
conn.create_function(name, arity, function, deterministic=True)
except sqlite3.NotSupportedError:
conn.create_function(name, arity, function)
def open_index_connection():
AUTH_DIR.mkdir(parents=True, exist_ok=True)
chmod_best_effort(AUTH_DIR, 0o700)
conn = sqlite3.connect(str(INDEX_PATH), timeout=30)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout = 5000")
register_index_functions(conn)
return conn
def local_index_type_clause(value, alias="n"):
"""WHERE clause matching a type filter on effective types only: a generic
note whose folder implies another type does not match `note`."""
types = requested_node_types(value)
if not types:
return None, []
placeholders = ",".join("?" for _ in types)
return f"nap_effective_type({alias}.node_type, {alias}.folder_path) IN ({placeholders})", types
def initialize_index_schema(conn):
conn.execute("PRAGMA journal_mode = WAL")
conn.execute(
@@ -7739,7 +7895,9 @@ def node_matches_search_intent(node, intent):
)
SEARCH_DEMOTED_STATUSES = frozenset(ACTIVE_EXCLUDED_STATUSES | {"deprecated", "obsolete"})
# Release statuses (deployed, released, ...) are terminal for active-only
# lists but often describe the current state, so they do not demote ranking.
SEARCH_DEMOTED_STATUSES = frozenset(ACTIVE_EXCLUDED_LIFECYCLE_STATUSES | {"deprecated", "obsolete"})
# Weighted relevance. Exact identity (the query is a node's name, title or
# basename) is decisive; everything else is additive so that one strong
# signal (an authoritative path) cannot hide a node that answers every term.
@@ -8125,6 +8283,7 @@ def search_local_index(args):
"title": row["title"],
"folder_path": row["folder_path"],
"type": row["node_type"],
"effective_type": effective_node_type(row["node_type"], row["folder_path"]),
"tags": json.loads(row["tags_json"] or "[]"),
"aliases_text": row["aliases_text"],
"aliases": json.loads(row["aliases_json"] or "[]"),
@@ -8229,9 +8388,10 @@ def local_index_filter_clauses(args, filters):
if args.get("folder_path") not in (None, ""):
where.append("n.folder_path = ?")
parameters.append(str(args["folder_path"]))
if args.get("type") not in (None, ""):
where.append("n.node_type = ?")
parameters.append(str(args["type"]))
type_clause, type_parameters = local_index_type_clause(args.get("type"))
if type_clause:
where.append(type_clause)
parameters.extend(type_parameters)
if args.get("tag") not in (None, ""):
where.append(
"EXISTS (SELECT 1 FROM local_index_node_tags t "
@@ -9955,6 +10115,7 @@ def e2e_shard_search(project_id, conn, match_query, analysis, filter_where, filt
"title": row["title"],
"folder_path": row["folder_path"],
"type": row["node_type"],
"effective_type": effective_node_type(row["node_type"], row["folder_path"]),
"tags": json.loads(row["tags_json"] or "[]"),
"aliases": json.loads(row["aliases_json"] or "[]"),
"status": row["status"],
@@ -12727,7 +12888,6 @@ def node_payload_from_args(args, existing=None):
elif not existing:
raise RuntimeError("path is required when creating a node")
for source, target in [
("type", "type"),
("content_text", "content_text"),
("tags", "tags"),
("aliases", "aliases"),
@@ -12736,8 +12896,14 @@ def node_payload_from_args(args, existing=None):
]:
if source in args:
payload[target] = args[source]
if "type" not in payload and not existing:
payload["type"] = "note"
if args.get("type") not in (None, ""):
payload["type"] = explicit_node_type(args["type"])
elif not existing:
# A create without a type takes its folder's type; otherwise the
# server default applies. Updates never retype a stored node.
node_type = create_node_type(None, payload.get("folder_path"))
if node_type is not None:
payload["type"] = node_type
if "content_text" not in payload and not existing:
payload["content_text"] = ""
if "tags" not in payload and not existing:
@@ -12823,13 +12989,16 @@ def create_node(args):
payload = {
"folder_path": folder_path,
"name": args["name"],
"type": args.get("type", "note"),
"tags": args.get("tags", []),
"aliases": args.get("aliases", []),
"links": args.get("links", []),
"metadata": args.get("metadata", {}),
"content_text": args["content_text"],
}
# Send the folder's type explicitly so older servers store typed nodes too.
node_type = create_node_type(args.get("type"), folder_path)
if node_type is not None:
payload["type"] = node_type
node = request_project_write(
"POST",
f"/v1/projects/{project_id}/nodes",
@@ -13069,9 +13238,10 @@ def browse_local_index(args, *, view, sort):
if args.get("folder_path") not in (None, ""):
where.append("n.folder_path = ?")
parameters.append(str(args["folder_path"]))
if args.get("type") not in (None, ""):
where.append("n.node_type = ?")
parameters.append(str(args["type"]))
type_clause, type_parameters = local_index_type_clause(args.get("type"))
if type_clause:
where.append(type_clause)
parameters.extend(type_parameters)
if args.get("tag") not in (None, ""):
where.append(
"EXISTS (SELECT 1 FROM local_index_node_tags t "
@@ -13143,6 +13313,7 @@ def browse_local_index(args, *, view, sort):
"_cursor_title_key": row["title_sort_key"],
"folder_path": row["folder_path"],
"type": row["node_type"],
"effective_type": effective_node_type(row["node_type"], row["folder_path"]),
"tags": json.loads(row["tags_json"] or "[]"),
"status": status,
"metadata_summary": {"status": status} if status else {},
@@ -13258,6 +13429,7 @@ def list_project_nodes(args):
result = request_json("GET", f"/v1/projects/{project_id}/nodes{suffix}")
items = decrypt_nodes_for_return(filter_project_nodes(result.get("items", [])), project_id=project_id)
items = apply_status_filters(items, filters)
items = apply_type_filter(items, base_query.get("type"))
raw_q = str(base_query.get("q") or "").strip()
query_analysis = result.get("query_analysis")
diagnostics = result.get("diagnostics")
@@ -15547,9 +15719,12 @@ def discover_memory(args):
requested_facets = unique_preserve_order(
str(value).strip().lower() for value in (args.get("facets") or []) if str(value).strip()
)
invalid_facets = sorted(set(requested_facets) - {"folder", "tag", "type", "status"})
invalid_facets = sorted(set(requested_facets) - {"folder", "tag", "type", "effective_type", "status"})
if invalid_facets:
raise RuntimeError("facets must contain only folder, tag, type, or status")
raise RuntimeError("facets must contain only folder, tag, type, effective_type, or status")
if "type" in requested_facets and "effective_type" not in requested_facets:
# "type" keeps counting stored types; effective types ride alongside.
requested_facets.append("effective_type")
if requested_facets:
counts = {}
for facet in requested_facets:
@@ -15561,6 +15736,8 @@ def discover_memory(args):
item_values = item.get("tags") or []
elif facet == "type":
item_values = [item.get("type")]
elif facet == "effective_type":
item_values = [node_effective_type(item)]
else:
item_values = [item.get("status") or node_status(item)]
for value in item_values:
@@ -18040,7 +18217,7 @@ def raw_tools():
"intent": {"type": "string", "enum": ["any", "decision", "rule", "plan", "task", "implementation", "documentation"], "default": "any", "description": "Optional ranking intent. It boosts canonical records of that class without hard-filtering other matches."},
"folder_path": {"type": "string"},
"tag": {"type": "string"},
"type": {"type": "string"},
"type": {"type": "string", "description": "Matches effective types: the canonical stored type, except a generic note takes its folder's type (a note under /plans matches plan, not note). Rows keep the stored type and add effective_type when it differs."},
"updated_after": {"type": "string", "description": "RFC3339 timestamp or YYYY-MM-DD lower bound on updated_at."},
"updated_before": {"type": "string", "description": "RFC3339 timestamp or YYYY-MM-DD upper bound on updated_at."},
"since": {"type": "string", "description": "Alias for updated_after."},
@@ -18431,6 +18608,15 @@ def raw_tools():
"properties": {
"folder_path": {"type": "string", "description": "Folder part of the canonical node path, for example /decisions or /incidents/2026-04-26."},
"name": {"type": "string", "description": "Human-readable node name. The server normalizes it into full_path."},
"type": {
"type": "string",
"description": (
"Optional. Canonical: " + ", ".join(sorted(CANONICAL_NODE_TYPES))
+ ". Spelling variants canonicalize; custom types are kept. When omitted, typed folders "
"decide (/plans -> plan, /decisions -> decision, /implementation-notes -> implementation_note, "
"/runbooks, /rules, /kanban, ...); other folders get note."
),
},
"content_text": {"type": "string"},
"tags": {"type": "array", "items": {"type": "string"}},
"links": {
@@ -20155,9 +20341,10 @@ def _efficient_memory_schema(name, schema):
}
properties["facets"] = {
"type": "array",
"items": {"type": "string", "enum": ["folder", "tag", "type", "status"]},
"items": {"type": "string", "enum": ["folder", "tag", "type", "effective_type", "status"]},
"uniqueItems": True,
"maxItems": 4,
"maxItems": 5,
"description": "Counts over returned rows. type counts stored types; effective_type (also added with type) counts folder-resolved types.",
}
elif name == "nap_context":
for key in ("shortcuts", "paths", "node_ids", "uris"):