ada-pc: 2026-06-16 20:13:30 | 8
Affected files: 99 System/Export/agent-tools/AGENT_TOOLS.md 99 System/Export/agent-tools/vault_audit.py 99 System/Export/agent-tools/vault_common.py 99 System/Export/agent-tools/vault_frontmatter.py 99 System/Export/agent-tools/vault_index.py 99 System/Export/agent-tools/vault_ingest.py 99 System/INDEX.md AGENTS.md
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
---
|
||||
title: Agent tools для vault
|
||||
status: stable
|
||||
type: guide
|
||||
tags: [agent, tooling, vault]
|
||||
created: 2026-06-16
|
||||
updated: 2026-06-16
|
||||
aliases: []
|
||||
---
|
||||
|
||||
# Agent tools для vault
|
||||
|
||||
Набор утилит для ИИ-агентов. Вывод — JSON, чтобы агент мог читать результат без парсинга Markdown-таблиц.
|
||||
|
||||
Запускать из корня vault:
|
||||
|
||||
```bash
|
||||
python3 "99 System/Export/agent-tools/vault_index.py" dump
|
||||
```
|
||||
|
||||
## Команды
|
||||
|
||||
### `vault_index.py`
|
||||
|
||||
- `dump` — вывести все заметки из vault как JSON.
|
||||
- `query --term "CI/CD" --limit 10` — найти близкие заметки.
|
||||
- `rebuild` — пересобрать `99 System/INDEX.md`. Это write-команда.
|
||||
|
||||
### `vault_ingest.py`
|
||||
|
||||
- `scan` — показать файлы из `00 Inbox/` и `01 Ideas/`.
|
||||
- `classify PATH` — эвристически классифицировать источник.
|
||||
- `plan PATH` — выдать JSON-план ingest: назначение, похожие заметки, возможные wikilinks.
|
||||
|
||||
Команды read-only. Они не заменяют обязательный план и подтверждение перед workflow.
|
||||
|
||||
### `vault_audit.py`
|
||||
|
||||
- `broken-links` — найти wikilinks на несуществующие заметки.
|
||||
- `orphans` — найти заметки без входящих ссылок.
|
||||
- `source-links` — проверить wikilinks в `source`.
|
||||
- `components` — найти компоненты графа ссылок.
|
||||
- `all` — выполнить все проверки.
|
||||
|
||||
Команды read-only.
|
||||
|
||||
### `vault_frontmatter.py`
|
||||
|
||||
- `check [PATH ...]` — проверить обязательные поля и допустимые `status/type`.
|
||||
- `normalize PATH ...` — нормализовать frontmatter выбранных файлов. Это write-команда.
|
||||
|
||||
## Правила для агентов
|
||||
|
||||
- Перед `ingest`, `wiki`, `audit`, `remind`, `query` сначала читать `99 System/INDEX.md`; скрипты помогают, но не отменяют правила `AGENTS.md`.
|
||||
- Write-команды запускать только когда workflow уже подтверждён пользователем или правило явно требует обновить индекс.
|
||||
- `vault_ingest.py plan` — вспомогательный план, не окончательное решение. Агент обязан проверить содержимое источника сам.
|
||||
- После создания, удаления, перемещения заметки или изменения frontmatter запускать `vault_index.py rebuild`.
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
from vault_common import all_records, json_print, known_note_names, source_values, wikilinks
|
||||
|
||||
|
||||
def broken_links(records: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||
known = known_note_names(records)
|
||||
result = []
|
||||
for record in records:
|
||||
links = wikilinks(record["body"])
|
||||
missing = sorted({link for link in links if link not in known})
|
||||
if missing:
|
||||
result.append({"path": record["path"], "missing": missing})
|
||||
return result
|
||||
|
||||
|
||||
def orphans(records: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||
incoming: Counter[str] = Counter()
|
||||
aliases: dict[str, str] = {}
|
||||
path_by_name: dict[str, str] = {}
|
||||
|
||||
for record in records:
|
||||
path_by_name[record["name"]] = record["path"]
|
||||
aliases[record["name"]] = record["name"]
|
||||
aliases[record["path"]] = record["name"]
|
||||
for alias in record["aliases"]:
|
||||
aliases[alias] = record["name"]
|
||||
|
||||
for record in records:
|
||||
for link in wikilinks(record["body"]):
|
||||
target = aliases.get(link)
|
||||
if target and target != record["name"]:
|
||||
incoming[target] += 1
|
||||
|
||||
result = []
|
||||
for name, path in sorted(path_by_name.items(), key=lambda item: item[1]):
|
||||
if incoming[name] == 0 and not path.startswith("99 System/Archive/"):
|
||||
result.append({"path": path, "name": name, "incoming": 0})
|
||||
return result
|
||||
|
||||
|
||||
def source_links(records: list[dict[str, object]]) -> dict[str, object]:
|
||||
known = known_note_names(records)
|
||||
linked = []
|
||||
missing = []
|
||||
plain = []
|
||||
|
||||
for record in records:
|
||||
for source in source_values(record["frontmatter"].get("source")):
|
||||
links = wikilinks(source)
|
||||
if links:
|
||||
for link in links:
|
||||
item = {"path": record["path"], "source": source, "target": link}
|
||||
if link in known:
|
||||
linked.append(item)
|
||||
else:
|
||||
missing.append(item)
|
||||
else:
|
||||
plain.append({"path": record["path"], "source": source})
|
||||
|
||||
return {"linked": linked, "missing": missing, "plain": plain}
|
||||
|
||||
|
||||
def graph_components(records: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||
known = known_note_names(records)
|
||||
name_by_link = {}
|
||||
for record in records:
|
||||
name_by_link[record["name"]] = record["name"]
|
||||
name_by_link[record["path"]] = record["name"]
|
||||
for alias in record["aliases"]:
|
||||
name_by_link[alias] = record["name"]
|
||||
|
||||
graph: dict[str, set[str]] = defaultdict(set)
|
||||
path_by_name = {record["name"]: record["path"] for record in records}
|
||||
for record in records:
|
||||
name = record["name"]
|
||||
graph.setdefault(name, set())
|
||||
for link in wikilinks(record["body"]):
|
||||
if link not in known:
|
||||
continue
|
||||
target = name_by_link.get(link)
|
||||
if target and target != name:
|
||||
graph[name].add(target)
|
||||
graph[target].add(name)
|
||||
|
||||
seen = set()
|
||||
components = []
|
||||
for name in sorted(graph):
|
||||
if name in seen:
|
||||
continue
|
||||
stack = [name]
|
||||
component = []
|
||||
seen.add(name)
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
component.append(current)
|
||||
for next_name in graph[current]:
|
||||
if next_name not in seen:
|
||||
seen.add(next_name)
|
||||
stack.append(next_name)
|
||||
components.append(
|
||||
{
|
||||
"size": len(component),
|
||||
"notes": [path_by_name[item] for item in sorted(component)[:20]],
|
||||
"truncated": len(component) > 20,
|
||||
}
|
||||
)
|
||||
|
||||
return sorted(components, key=lambda item: item["size"])
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Agent-facing vault audit helper")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
sub.add_parser("broken-links")
|
||||
sub.add_parser("orphans")
|
||||
sub.add_parser("source-links")
|
||||
sub.add_parser("components")
|
||||
sub.add_parser("all")
|
||||
args = parser.parse_args()
|
||||
|
||||
records = all_records()
|
||||
if args.command == "broken-links":
|
||||
json_print({"ok": True, "broken_links": broken_links(records)})
|
||||
elif args.command == "orphans":
|
||||
json_print({"ok": True, "orphans": orphans(records)})
|
||||
elif args.command == "source-links":
|
||||
json_print({"ok": True, "source_links": source_links(records)})
|
||||
elif args.command == "components":
|
||||
json_print({"ok": True, "components": graph_components(records)})
|
||||
elif args.command == "all":
|
||||
json_print(
|
||||
{
|
||||
"ok": True,
|
||||
"broken_links": broken_links(records),
|
||||
"orphans": orphans(records),
|
||||
"source_links": source_links(records),
|
||||
"components": graph_components(records),
|
||||
}
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
INDEX_PATH = ROOT / "99 System" / "INDEX.md"
|
||||
EXCLUDED_DIRS = {".git", ".trash", ".obsidian", ".smart-env"}
|
||||
REQUIRED_FRONTMATTER = {"title", "status", "type", "tags", "created", "updated"}
|
||||
VALID_STATUS = {"seed", "processing", "stable"}
|
||||
VALID_TYPE = {"concept", "moc", "lab", "knowledge", "guide"}
|
||||
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except Exception: # pragma: no cover - depends on agent environment
|
||||
yaml = None
|
||||
|
||||
|
||||
def rel(path: Path) -> str:
|
||||
return path.relative_to(ROOT).as_posix()
|
||||
|
||||
|
||||
def json_print(data: Any) -> None:
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def note_files(include_index: bool = False) -> list[Path]:
|
||||
files: list[Path] = []
|
||||
for path in ROOT.rglob("*.md"):
|
||||
parts = set(path.relative_to(ROOT).parts)
|
||||
if parts & EXCLUDED_DIRS:
|
||||
continue
|
||||
if not include_index and path == INDEX_PATH:
|
||||
continue
|
||||
files.append(path)
|
||||
return sorted(files, key=lambda p: rel(p).lower())
|
||||
|
||||
|
||||
def split_frontmatter(text: str) -> tuple[dict[str, Any], str, str | None]:
|
||||
lines = text.splitlines()
|
||||
if not lines or lines[0].strip() != "---":
|
||||
return {}, text, None
|
||||
|
||||
end = None
|
||||
for index, line in enumerate(lines[1:], start=1):
|
||||
if line.strip() == "---":
|
||||
end = index
|
||||
break
|
||||
|
||||
if end is None:
|
||||
return {}, text, None
|
||||
|
||||
raw = "\n".join(lines[1:end])
|
||||
body = "\n".join(lines[end + 1 :])
|
||||
return parse_frontmatter(raw), body, raw
|
||||
|
||||
|
||||
def parse_frontmatter(raw: str) -> dict[str, Any]:
|
||||
if yaml is not None:
|
||||
try:
|
||||
data = yaml.safe_load(raw) or {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
data: dict[str, Any] = {}
|
||||
current_key: str | None = None
|
||||
|
||||
for line in raw.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
list_item = re.match(r"^\s*-\s+(.*)$", line)
|
||||
if list_item and current_key:
|
||||
data.setdefault(current_key, [])
|
||||
if isinstance(data[current_key], list):
|
||||
data[current_key].append(clean_scalar(list_item.group(1)))
|
||||
continue
|
||||
|
||||
pair = re.match(r"^([A-Za-zА-Яа-я0-9_-]+):\s*(.*)$", line)
|
||||
if not pair:
|
||||
continue
|
||||
|
||||
key, value = pair.group(1), pair.group(2).strip()
|
||||
current_key = key
|
||||
if value == "":
|
||||
data[key] = [] if key in {"aliases", "tags"} else ""
|
||||
elif value.startswith("[") and value.endswith("]"):
|
||||
data[key] = parse_inline_list(value)
|
||||
else:
|
||||
data[key] = clean_scalar(value)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def parse_inline_list(value: str) -> list[str]:
|
||||
inner = value.strip()[1:-1].strip()
|
||||
if not inner:
|
||||
return []
|
||||
return [clean_scalar(item.strip()) for item in inner.split(",") if item.strip()]
|
||||
|
||||
|
||||
def clean_scalar(value: Any) -> str:
|
||||
text = str(value).strip()
|
||||
if len(text) >= 2 and text[0] == text[-1] and text[0] in {"'", '"'}:
|
||||
return text[1:-1]
|
||||
return text
|
||||
|
||||
|
||||
def as_list(value: Any) -> list[str]:
|
||||
if value is None or value == "":
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return [clean_scalar(item) for item in value if clean_scalar(item)]
|
||||
return [clean_scalar(value)]
|
||||
|
||||
|
||||
def as_cell(value: Any, default: str = "—") -> str:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
if isinstance(value, (date, datetime)):
|
||||
return value.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
def tags_cell(value: Any) -> str:
|
||||
tags = as_list(value)
|
||||
return "[" + ", ".join(tags) + "]"
|
||||
|
||||
|
||||
def note_record(path: Path) -> dict[str, Any]:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
fm, body, raw_fm = split_frontmatter(text)
|
||||
return {
|
||||
"path": rel(path),
|
||||
"name": path.stem,
|
||||
"title": as_cell(fm.get("title"), ""),
|
||||
"aliases": as_list(fm.get("aliases")),
|
||||
"status": as_cell(fm.get("status")),
|
||||
"type": as_cell(fm.get("type")),
|
||||
"tags": as_list(fm.get("tags")),
|
||||
"created": as_cell(fm.get("created"), ""),
|
||||
"updated": as_cell(fm.get("updated"), ""),
|
||||
"frontmatter": fm,
|
||||
"has_frontmatter": raw_fm is not None,
|
||||
"body": body,
|
||||
}
|
||||
|
||||
|
||||
def all_records() -> list[dict[str, Any]]:
|
||||
return [note_record(path) for path in note_files()]
|
||||
|
||||
|
||||
def wikilinks(text: str) -> list[str]:
|
||||
links = []
|
||||
for match in re.finditer(r"\[\[([^\]]+)\]\]", text):
|
||||
target = match.group(1).split("|", 1)[0].split("#", 1)[0].strip()
|
||||
if target:
|
||||
links.append(target)
|
||||
return links
|
||||
|
||||
|
||||
def known_note_names(records: list[dict[str, Any]]) -> set[str]:
|
||||
names: set[str] = set()
|
||||
for record in records:
|
||||
names.add(record["name"])
|
||||
names.add(record["path"])
|
||||
names.update(record["aliases"])
|
||||
return names
|
||||
|
||||
|
||||
def tokenize(value: str) -> set[str]:
|
||||
return {token.lower() for token in re.findall(r"[A-Za-zА-Яа-я0-9_+-]+", value)}
|
||||
|
||||
|
||||
def search_records(records: list[dict[str, Any]], term: str, limit: int = 10) -> list[dict[str, Any]]:
|
||||
query = tokenize(term)
|
||||
if not query:
|
||||
return []
|
||||
|
||||
scored = []
|
||||
for record in records:
|
||||
haystack = " ".join(
|
||||
[
|
||||
record["name"],
|
||||
record["title"],
|
||||
" ".join(record["aliases"]),
|
||||
" ".join(record["tags"]),
|
||||
record["path"],
|
||||
]
|
||||
)
|
||||
tokens = tokenize(haystack)
|
||||
score = len(query & tokens)
|
||||
if term.lower() in haystack.lower():
|
||||
score += 3
|
||||
if score:
|
||||
scored.append((score, record))
|
||||
|
||||
scored.sort(key=lambda item: (-item[0], item[1]["path"]))
|
||||
return [compact_record(record) | {"score": score} for score, record in scored[:limit]]
|
||||
|
||||
|
||||
def compact_record(record: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"path": record["path"],
|
||||
"name": record["name"],
|
||||
"title": record["title"],
|
||||
"aliases": record["aliases"],
|
||||
"status": record["status"],
|
||||
"type": record["type"],
|
||||
"tags": record["tags"],
|
||||
"created": record["created"],
|
||||
"updated": record["updated"],
|
||||
}
|
||||
|
||||
|
||||
def write_index(records: list[dict[str, Any]]) -> None:
|
||||
by_dir: dict[str, list[dict[str, Any]]] = {}
|
||||
for record in records:
|
||||
directory = str(Path(record["path"]).parent)
|
||||
if directory == ".":
|
||||
directory = "."
|
||||
by_dir.setdefault(directory, []).append(record)
|
||||
|
||||
total = len(records)
|
||||
lines = [
|
||||
"---",
|
||||
f"generated: {date.today().isoformat()}",
|
||||
f"total: {total}",
|
||||
"---",
|
||||
"",
|
||||
"# Vault Index",
|
||||
"",
|
||||
"## Статистика",
|
||||
"",
|
||||
"| Параметр | Значение |",
|
||||
"|----------|----------|",
|
||||
f"| Всего заметок | {total} |",
|
||||
"",
|
||||
]
|
||||
|
||||
for directory in sorted(by_dir, key=str.lower):
|
||||
lines.extend(
|
||||
[
|
||||
f"## {directory}",
|
||||
"",
|
||||
"| Заметка | Aliases | Status | Type | Tags |",
|
||||
"|---------|---------|--------|------|------|",
|
||||
]
|
||||
)
|
||||
for record in sorted(by_dir[directory], key=lambda item: item["name"].lower()):
|
||||
aliases = ", ".join(record["aliases"]) if record["aliases"] else "—"
|
||||
status = record["status"] or "—"
|
||||
note_type = record["type"] or "—"
|
||||
tags = "[" + ", ".join(record["tags"]) + "]"
|
||||
lines.append(f"| [[{record['name']}]] | {aliases} | {status} | {note_type} | {tags} |")
|
||||
lines.append("")
|
||||
|
||||
INDEX_PATH.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
def source_values(value: Any) -> list[str]:
|
||||
if isinstance(value, list):
|
||||
return [clean_scalar(item) for item in value if clean_scalar(item)]
|
||||
if value:
|
||||
return [clean_scalar(value)]
|
||||
return []
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from vault_common import (
|
||||
REQUIRED_FRONTMATTER,
|
||||
ROOT,
|
||||
VALID_STATUS,
|
||||
VALID_TYPE,
|
||||
all_records,
|
||||
as_list,
|
||||
json_print,
|
||||
note_record,
|
||||
note_files,
|
||||
rel,
|
||||
split_frontmatter,
|
||||
)
|
||||
|
||||
|
||||
def resolve_paths(paths: list[str]) -> list[Path]:
|
||||
if not paths:
|
||||
return note_files()
|
||||
result = []
|
||||
for item in paths:
|
||||
path = Path(item)
|
||||
if not path.is_absolute():
|
||||
path = ROOT / path
|
||||
result.append(path)
|
||||
return result
|
||||
|
||||
|
||||
def validate_record(record: dict[str, object]) -> list[str]:
|
||||
fm = record["frontmatter"]
|
||||
issues = []
|
||||
|
||||
if not record["has_frontmatter"]:
|
||||
issues.append("missing_frontmatter")
|
||||
|
||||
for field in sorted(REQUIRED_FRONTMATTER):
|
||||
if field not in fm or fm[field] in (None, ""):
|
||||
issues.append(f"missing_{field}")
|
||||
|
||||
if fm.get("status") and str(fm["status"]) not in VALID_STATUS:
|
||||
issues.append("invalid_status")
|
||||
if fm.get("type") and str(fm["type"]) not in VALID_TYPE:
|
||||
issues.append("invalid_type")
|
||||
if "tags" in fm and not isinstance(fm["tags"], list):
|
||||
issues.append("tags_not_list")
|
||||
if "aliases" in fm and fm["aliases"] not in (None, "") and not isinstance(fm["aliases"], list):
|
||||
issues.append("aliases_not_list")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def check(paths: list[Path]) -> list[dict[str, object]]:
|
||||
results = []
|
||||
for path in paths:
|
||||
record = note_record(path)
|
||||
issues = validate_record(record)
|
||||
if issues:
|
||||
results.append({"path": rel(path), "issues": issues})
|
||||
return results
|
||||
|
||||
|
||||
def yaml_list(values: list[str]) -> list[str]:
|
||||
if not values:
|
||||
return ["[]"]
|
||||
return [""] + [f" - {value}" for value in values]
|
||||
|
||||
|
||||
def normalize(path: Path) -> dict[str, object]:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
fm, body, _ = split_frontmatter(text)
|
||||
title = fm.get("title") or path.stem
|
||||
status = fm.get("status") if fm.get("status") in VALID_STATUS else "seed"
|
||||
note_type = fm.get("type") if fm.get("type") in VALID_TYPE else "concept"
|
||||
tags = as_list(fm.get("tags"))
|
||||
aliases = as_list(fm.get("aliases"))
|
||||
created = fm.get("created") or date.today().isoformat()
|
||||
updated = date.today().isoformat()
|
||||
|
||||
lines = [
|
||||
"---",
|
||||
f"title: {title}",
|
||||
f"status: {status}",
|
||||
f"type: {note_type}",
|
||||
"tags:" + ("\n".join(yaml_list(tags)) if tags else " []"),
|
||||
f"created: {created}",
|
||||
f"updated: {updated}",
|
||||
"aliases:" + ("\n".join(yaml_list(aliases)) if aliases else " []"),
|
||||
]
|
||||
|
||||
for key, value in fm.items():
|
||||
if key in {"title", "status", "type", "tags", "created", "updated", "aliases"}:
|
||||
continue
|
||||
if isinstance(value, list):
|
||||
lines.append(f"{key}:")
|
||||
lines.extend(f" - {item}" for item in value)
|
||||
elif value in (None, ""):
|
||||
lines.append(f"{key}:")
|
||||
else:
|
||||
lines.append(f"{key}: {value}")
|
||||
|
||||
lines.extend(["---", "", body.lstrip("\n")])
|
||||
path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return {"path": rel(path), "written": True}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Agent-facing frontmatter helper")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
check_cmd = sub.add_parser("check")
|
||||
check_cmd.add_argument("paths", nargs="*")
|
||||
|
||||
normalize_cmd = sub.add_parser("normalize")
|
||||
normalize_cmd.add_argument("paths", nargs="+")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "check":
|
||||
issues = check(resolve_paths(args.paths))
|
||||
json_print({"ok": True, "issues": issues, "total_with_issues": len(issues)})
|
||||
elif args.command == "normalize":
|
||||
written = [normalize(path) for path in resolve_paths(args.paths)]
|
||||
json_print({"ok": True, "written": written})
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from vault_common import all_records, compact_record, json_print, search_records, write_index
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Agent-facing vault index helper")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
query = sub.add_parser("query", help="Search notes by term")
|
||||
query.add_argument("--term", required=True)
|
||||
query.add_argument("--limit", type=int, default=10)
|
||||
|
||||
sub.add_parser("dump", help="Dump note records as JSON")
|
||||
sub.add_parser("rebuild", help="Rebuild 99 System/INDEX.md")
|
||||
|
||||
args = parser.parse_args()
|
||||
records = all_records()
|
||||
|
||||
if args.command == "query":
|
||||
json_print({"ok": True, "results": search_records(records, args.term, args.limit)})
|
||||
elif args.command == "dump":
|
||||
json_print({"ok": True, "total": len(records), "notes": [compact_record(r) for r in records]})
|
||||
elif args.command == "rebuild":
|
||||
write_index(records)
|
||||
json_print({"ok": True, "written": "99 System/INDEX.md", "total": len(records)})
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from vault_common import ROOT, all_records, json_print, rel, search_records, split_frontmatter, tokenize
|
||||
|
||||
INBOX_DIRS = [ROOT / "00 Inbox", ROOT / "01 Ideas"]
|
||||
LIBRARY_HINTS = {
|
||||
"ansible": "90 Library/DevOps/Ansible",
|
||||
"ci": "90 Library/DevOps",
|
||||
"cd": "90 Library/DevOps",
|
||||
"cicd": "90 Library/DevOps",
|
||||
"docker": "90 Library/Containers/Docker",
|
||||
"git": "90 Library/DevOps",
|
||||
"gitlab": "90 Library/DevOps",
|
||||
"jenkins": "90 Library/DevOps",
|
||||
"kubernetes": "90 Library/Containers/Kubernetes",
|
||||
"linux": "90 Library/Linux",
|
||||
"nginx": "90 Library/HomeLab/Proxy",
|
||||
"rails": "90 Library/Programming/Ruby On Rails",
|
||||
"ruby": "90 Library/Programming/Ruby",
|
||||
"terraform": "90 Library/DevOps",
|
||||
}
|
||||
|
||||
|
||||
def read_path(path_arg: str) -> Path:
|
||||
path = Path(path_arg)
|
||||
if not path.is_absolute():
|
||||
path = ROOT / path
|
||||
return path
|
||||
|
||||
|
||||
def scan() -> list[dict[str, object]]:
|
||||
items = []
|
||||
for directory in INBOX_DIRS:
|
||||
if not directory.exists():
|
||||
continue
|
||||
for path in sorted(directory.iterdir(), key=lambda item: item.name.lower()):
|
||||
if path.name == ".gitkeep" or not path.is_file():
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8", errors="replace") if path.suffix == ".md" else ""
|
||||
fm, body, _ = split_frontmatter(text)
|
||||
items.append(
|
||||
{
|
||||
"path": rel(path),
|
||||
"folder": rel(directory),
|
||||
"suffix": path.suffix,
|
||||
"title": fm.get("title") or path.stem,
|
||||
"tags": fm.get("tags") or [],
|
||||
"bytes": path.stat().st_size,
|
||||
"classification": classify_text(path, body, fm),
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def classify_text(path: Path, body: str, frontmatter: dict[str, object]) -> dict[str, object]:
|
||||
tags = frontmatter.get("tags") or []
|
||||
if not isinstance(tags, list):
|
||||
tags = [tags]
|
||||
content = f"{path.stem}\n{' '.join(map(str, tags))}\n{body[:4000]}".lower()
|
||||
tokens = tokenize(content)
|
||||
|
||||
idea_markers = {"идея", "idea", "business", "product", "стартап", "концепция", "задумка"}
|
||||
if "01 Ideas" in rel(path) or tokens & idea_markers:
|
||||
kind = "idea"
|
||||
destination = "01 Ideas"
|
||||
else:
|
||||
kind = "library"
|
||||
destination = "90 Library/Other"
|
||||
for marker, folder in LIBRARY_HINTS.items():
|
||||
if marker in content:
|
||||
destination = folder
|
||||
break
|
||||
|
||||
return {
|
||||
"kind": kind,
|
||||
"destination": destination,
|
||||
"confidence": "heuristic",
|
||||
}
|
||||
|
||||
|
||||
def plan(path: Path) -> dict[str, object]:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
fm, body, _ = split_frontmatter(text)
|
||||
classification = classify_text(path, body, fm)
|
||||
records = all_records()
|
||||
terms = " ".join([path.stem, str(fm.get("title") or ""), " ".join(map(str, fm.get("tags") or []))])
|
||||
similar = []
|
||||
for item in search_records(records, terms, limit=30):
|
||||
if item["path"] == rel(path):
|
||||
continue
|
||||
if item["path"].startswith(("03 Journal/", "99 System/Export/", "99 System/Cache/", "99 System/Template/")):
|
||||
continue
|
||||
similar.append(item)
|
||||
if len(similar) == 8:
|
||||
break
|
||||
wikilinks = [item["name"] for item in similar[:5]]
|
||||
|
||||
return {
|
||||
"path": rel(path),
|
||||
"classification": classification,
|
||||
"similar_notes": similar,
|
||||
"suggested_wikilinks": wikilinks,
|
||||
"write_policy": "read-only plan; agent must ask before workflow changes",
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Agent-facing ingest helper")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
sub.add_parser("scan", help="List files in 00 Inbox and 01 Ideas")
|
||||
classify = sub.add_parser("classify", help="Classify one source file")
|
||||
classify.add_argument("path")
|
||||
plan_cmd = sub.add_parser("plan", help="Build JSON ingest plan for one source file")
|
||||
plan_cmd.add_argument("path")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "scan":
|
||||
json_print({"ok": True, "items": scan()})
|
||||
elif args.command == "classify":
|
||||
path = read_path(args.path)
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
fm, body, _ = split_frontmatter(text)
|
||||
json_print({"ok": True, "path": rel(path), "classification": classify_text(path, body, fm)})
|
||||
elif args.command == "plan":
|
||||
json_print({"ok": True, "plan": plan(read_path(args.path))})
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+9
-3
@@ -1,6 +1,6 @@
|
||||
---
|
||||
generated: 2026-06-16
|
||||
total: 515
|
||||
total: 516
|
||||
---
|
||||
|
||||
# Vault Index
|
||||
@@ -9,13 +9,13 @@ total: 515
|
||||
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| Всего заметок | 515 |
|
||||
| Всего заметок | 516 |
|
||||
|
||||
## .
|
||||
|
||||
| Заметка | Aliases | Status | Type | Tags |
|
||||
|---------|---------|--------|------|------|
|
||||
| [[AGENTS]] | Агент — контекст и инструкции для этого хранилища | — | — | [] |
|
||||
| [[AGENTS]] | Агент — контекст и инструкции для этого хранилища | stable | guide | [agent, vault] |
|
||||
| [[CLAUDE]] | CLAUDE | — | — | [] |
|
||||
| [[CODEX]] | CODEX | — | — | [] |
|
||||
| [[GEMINI]] | GEMINI | — | — | [] |
|
||||
@@ -821,6 +821,12 @@ total: 515
|
||||
| [[Drawing 2026-05-17 14.13.00.excalidraw]] | — | — | — | [excalidraw] |
|
||||
| [[infra.excalidraw.md]] | — | — | — | [excalidraw] |
|
||||
|
||||
## 99 System/Export/agent-tools
|
||||
|
||||
| Заметка | Aliases | Status | Type | Tags |
|
||||
|---------|---------|--------|------|------|
|
||||
| [[AGENT_TOOLS]] | — | stable | guide | [agent, tooling, vault] |
|
||||
|
||||
## 99 System/Template
|
||||
|
||||
| Заметка | Aliases | Status | Type | Tags |
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
---
|
||||
created: 2026-04-20
|
||||
updated: 2026-06-05
|
||||
updated: 2026-06-16
|
||||
title: Агент — контекст и инструкции для этого хранилища
|
||||
status: stable
|
||||
type: guide
|
||||
tags: [agent, vault]
|
||||
aliases: ["Агент — контекст и инструкции для этого хранилища"]
|
||||
---
|
||||
|
||||
@@ -86,6 +90,16 @@ aliases: ["Агент — контекст и инструкции для это
|
||||
- **Обновлять** после создания/удаления/перемещения заметки или изменения frontmatter.
|
||||
- **Как:** пройтись по всем `.md` (исключая `.trash`, `.obsidian`, `.git`, `.smart-env`, сам `99 System/INDEX.md`), извлечь frontmatter, перезаписать файл в том же формате (frontmatter с `generated`/`total`, таблицы по секциям).
|
||||
|
||||
## Agent tools
|
||||
|
||||
Утилиты для ИИ-агентов: `99 System/Export/agent-tools/`.
|
||||
|
||||
- Документация: `99 System/Export/agent-tools/AGENT_TOOLS.md`.
|
||||
- Вывод скриптов — JSON, чтобы агент мог использовать результат без парсинга Markdown.
|
||||
- Read-only команды можно использовать для ориентации: поиск по индексу, план ingest, audit, проверка frontmatter.
|
||||
- Write-команды (`vault_index.py rebuild`, `vault_frontmatter.py normalize`) запускать только после подтверждённого workflow или когда правило явно требует обновить `INDEX.md`.
|
||||
- Скрипты помогают, но не заменяют чтение исходников и правила этого файла.
|
||||
|
||||
## Workflow: Ingest
|
||||
|
||||
Триггер: `ingest` или новые файлы в `00 Inbox/`.
|
||||
|
||||
Reference in New Issue
Block a user