@bcmyguest/openmem 0.0.0-stage → 0.8.0
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.
- package/.agents/skills/openmem/SKILL.md +34 -0
- package/LICENSE +190 -0
- package/README.md +140 -2
- package/bin/hindsight-project-index +530 -0
- package/cli/coding-agent/access-policy.mjs +38 -0
- package/cli/coding-agent/agents.mjs +34 -0
- package/cli/coding-agent/bank-from-cwd.mjs +140 -0
- package/cli/coding-agent/capabilities.mjs +34 -0
- package/cli/coding-agent/context-store.mjs +81 -0
- package/cli/coding-agent/context-trim.mjs +897 -0
- package/cli/coding-agent/file-io.mjs +72 -0
- package/cli/coding-agent/hook-harnesses.mjs +88 -0
- package/cli/coding-agent/hook-runner.mjs +284 -0
- package/cli/coding-agent/install-access.mjs +119 -0
- package/cli/coding-agent/install-context-proxy.mjs +333 -0
- package/cli/coding-agent/install-rtk.mjs +60 -0
- package/cli/coding-agent/install-shim.mjs +201 -0
- package/cli/coding-agent/opencode-config.mjs +107 -0
- package/cli/coding-agent/opencode-plugin.mjs +132 -0
- package/cli/coding-agent/opencode2-plugin/index.js +299 -0
- package/cli/coding-agent/recall-frame.mjs +29 -0
- package/cli/coding-agent/remove-upstream-access.mjs +122 -0
- package/cli/coding-agent/runtime.mjs +40 -0
- package/cli/coding-agent/session-db.mjs +626 -0
- package/cli/coding-agent/session-facade.mjs +86 -0
- package/cli/coding-agent/toml-edit.mjs +161 -0
- package/cli/cognee-mcp.mjs +260 -0
- package/cli/context-management.mjs +254 -0
- package/cli/feed-sync.mjs +27 -0
- package/cli/install-coding-agent.mjs +298 -0
- package/cli/install-memory-mcp.mjs +426 -0
- package/cli/is-main.mjs +18 -0
- package/cli/mcp/context-sql-runner.mjs +58 -0
- package/cli/mcp/context-store.mjs +670 -0
- package/cli/mcp/context.mjs +61 -0
- package/cli/mcp/export/cognee-dataset-name.mjs +12 -0
- package/cli/mcp/export/cognee.mjs +119 -0
- package/cli/mcp/export/hindsight.mjs +48 -0
- package/cli/mcp/export/index.mjs +26 -0
- package/cli/mcp/export/mem0.mjs +31 -0
- package/cli/mcp/knowledge.mjs +333 -0
- package/cli/mcp/llm-synthesize.mjs +50 -0
- package/cli/mcp/mem0-scope-id.mjs +22 -0
- package/cli/mcp/memory-facade.mjs +521 -0
- package/cli/mcp/memory-reflect-consolidate.mjs +120 -0
- package/cli/mcp/notebooklm-banks.mjs +21 -0
- package/cli/mcp/notebooklm-hindsight-rest.mjs +45 -0
- package/cli/mcp/notebooklm-provider.mjs +225 -0
- package/cli/mcp/pmm-records.mjs +23 -0
- package/cli/mcp/portability.mjs +158 -0
- package/cli/mcp/stdio.mjs +65 -0
- package/cli/mcp/unified-mcp-install.mjs +65 -0
- package/cli/mem0-mcp.mjs +108 -0
- package/cli/notebooklm-mcp.mjs +236 -0
- package/cli/openmem-mcp.mjs +38 -0
- package/cli/openmem.mjs +222 -0
- package/cli/roots.mjs +85 -0
- package/cli/schedule.mjs +176 -0
- package/cli/stack.mjs +438 -0
- package/cli/switch-memory-provider.mjs +358 -0
- package/cli/wiring-report.mjs +125 -0
- package/compose.bench.yaml +12 -0
- package/compose.yaml +227 -0
- package/config/coding-agent.json +13 -0
- package/config/hindsight.env.example +26 -0
- package/config/index.yml +54 -0
- package/config/litellm.lemond.yaml +40 -0
- package/config/litellm.ollama.yaml +33 -0
- package/config/notebooklm.yml +33 -0
- package/config/openmem.toml.example +124 -0
- package/docs/OPENMEM.md +23 -0
- package/docs/architecture.md +245 -0
- package/docs/assets/README.md +10 -0
- package/docs/assets/setup-demo.gif +0 -0
- package/docs/coding-agent-hooks.md +75 -0
- package/docs/coding-agent-scopes.md +22 -0
- package/docs/coding-agents.md +71 -0
- package/docs/cognee.md +37 -0
- package/docs/compatibility.md +80 -0
- package/docs/concepts.md +135 -0
- package/docs/context-management.md +255 -0
- package/docs/context-proxy.md +115 -0
- package/docs/context-research.md +215 -0
- package/docs/context-store.md +277 -0
- package/docs/hindsight.md +77 -0
- package/docs/index-sources-roadmap.md +38 -0
- package/docs/index-sources.md +39 -0
- package/docs/indexer-sinks.md +122 -0
- package/docs/indexing.md +57 -0
- package/docs/manual-stack-setup.md +74 -0
- package/docs/mcp-install-guide.md +4 -0
- package/docs/mcp-setup.md +104 -0
- package/docs/mem0.md +141 -0
- package/docs/memory-portability.md +73 -0
- package/docs/memory-providers.md +246 -0
- package/docs/meridian-litellm.md +198 -0
- package/docs/model-configuration.md +106 -0
- package/docs/notebooklm.md +83 -0
- package/docs/npx-install.md +172 -0
- package/docs/openmem-config.md +194 -0
- package/docs/provider-wizard-hooks.md +222 -0
- package/docs/quickstart.md +107 -0
- package/docs/setup-wizard.md +117 -0
- package/docs/sources-feeds.md +39 -0
- package/docs/sources-readwise.md +23 -0
- package/docs/sources-zotero.md +22 -0
- package/glama.json +4 -0
- package/indexer/Dockerfile +38 -0
- package/indexer/code_outline.py +146 -0
- package/indexer/feed_sync.py +107 -0
- package/indexer/index_config.py +290 -0
- package/indexer/pytest.ini +5 -0
- package/indexer/sinks/__init__.py +148 -0
- package/indexer/sinks/_http.py +44 -0
- package/indexer/sinks/cognee.py +329 -0
- package/indexer/sinks/hindsight.py +107 -0
- package/indexer/sinks/mem0.py +247 -0
- package/indexer/watch.py +240 -0
- package/package.json +96 -4
- package/providers/_template/compose.yaml +52 -0
- package/providers/cognee/OPERATOR.md +82 -0
- package/providers/cognee/compose.yaml +89 -0
- package/providers/cognee/lib/clack.mjs +157 -0
- package/providers/cognee/lib/coding-agent.mjs +26 -0
- package/providers/cognee/lib/cognee-mcp-install.mjs +153 -0
- package/providers/cognee/lib/config.mjs +98 -0
- package/providers/cognee/lib/env-file.mjs +194 -0
- package/providers/cognee/lib/env-setup.mjs +83 -0
- package/providers/cognee/lib/llm-env.mjs +382 -0
- package/providers/cognee/lib/ports.mjs +25 -0
- package/providers/cognee/lib/smoke.mjs +246 -0
- package/providers/cognee/lib/stack-env.mjs +134 -0
- package/providers/cognee/lib/state.mjs +101 -0
- package/providers/cognee/lib/summary.mjs +56 -0
- package/providers/cognee/lib/validate.mjs +97 -0
- package/providers/cognee/manifest.json +56 -0
- package/providers/cognee/wizard.mjs +134 -0
- package/providers/graphiti/manifest.json +15 -0
- package/providers/hindsight/lib/clack.mjs +347 -0
- package/providers/hindsight/lib/coding-agent.mjs +204 -0
- package/providers/hindsight/lib/config.mjs +79 -0
- package/providers/hindsight/lib/env-setup.mjs +72 -0
- package/providers/hindsight/lib/litellm-routes.mjs +196 -0
- package/providers/hindsight/lib/meridian.mjs +307 -0
- package/providers/hindsight/lib/model-backend.mjs +243 -0
- package/providers/hindsight/lib/notebooklm-mcp-install.mjs +127 -0
- package/providers/hindsight/lib/ollama-compose.mjs +51 -0
- package/providers/hindsight/lib/ports.mjs +30 -0
- package/providers/hindsight/lib/probe.mjs +54 -0
- package/providers/hindsight/lib/stack-env.mjs +125 -0
- package/providers/hindsight/lib/state.mjs +80 -0
- package/providers/hindsight/lib/summary.mjs +57 -0
- package/providers/hindsight/lib/validate.mjs +81 -0
- package/providers/hindsight/manifest.json +58 -0
- package/providers/hindsight/wizard.mjs +150 -0
- package/providers/letta/manifest.json +29 -0
- package/providers/mem0/OPERATOR.md +143 -0
- package/providers/mem0/compose.yaml +79 -0
- package/providers/mem0/lib/coding-agent.mjs +58 -0
- package/providers/mem0/lib/config.mjs +45 -0
- package/providers/mem0/lib/configure.mjs +201 -0
- package/providers/mem0/lib/dotenv.mjs +71 -0
- package/providers/mem0/lib/env-file.mjs +110 -0
- package/providers/mem0/lib/http.mjs +69 -0
- package/providers/mem0/lib/litellm.mjs +457 -0
- package/providers/mem0/lib/mcp-install.mjs +165 -0
- package/providers/mem0/lib/secret.mjs +30 -0
- package/providers/mem0/lib/smoke.mjs +179 -0
- package/providers/mem0/lib/stack-env.mjs +255 -0
- package/providers/mem0/lib/state.mjs +67 -0
- package/providers/mem0/lib/summary.mjs +88 -0
- package/providers/mem0/lib/url.mjs +106 -0
- package/providers/mem0/lib/validate.mjs +158 -0
- package/providers/mem0/manifest.json +47 -0
- package/providers/mem0/wizard.mjs +146 -0
- package/providers/zep/manifest.json +29 -0
- package/proxy/config.yaml +33 -0
- package/proxy/openmem_context/__init__.py +6 -0
- package/proxy/openmem_context/adapters.py +504 -0
- package/proxy/openmem_context/compress.py +53 -0
- package/proxy/openmem_context/core.py +606 -0
- package/proxy/openmem_context/embed.py +212 -0
- package/proxy/openmem_context/recall.py +49 -0
- package/proxy/openmem_context/session.py +70 -0
- package/proxy/openmem_context/sessiondb.py +276 -0
- package/proxy/openmem_context/settings.py +134 -0
- package/proxy/openmem_context/store.py +129 -0
- package/proxy/openmem_context/tokens.py +123 -0
- package/proxy/openmem_context/ttl.py +78 -0
- package/proxy/openmem_context_hook.py +34 -0
- package/scripts/check-pack.mjs +219 -0
- package/scripts/gen-masks.mjs +83 -0
- package/scripts/gen-masks.sh +4 -0
- package/scripts/promotion-provider-smoke.mjs +34 -0
- package/scripts/run-setup.sh +30 -0
- package/scripts/setup.mjs +107 -0
- package/scripts/setup.sh +5 -0
- package/scripts/spin-up.sh +45 -0
- package/scripts/wizard.sh +5 -0
- package/server.json +34 -0
- package/sync/Dockerfile +27 -0
- package/sync/memory_backend.py +87 -0
- package/sync/notebooklm_sync.py +664 -0
- package/wizard/bin/pm.mjs +97 -0
- package/wizard/bin/setup.mjs +33 -0
- package/wizard/lib/compose-version.mjs +32 -0
- package/wizard/lib/compose.mjs +70 -0
- package/wizard/lib/context-step.mjs +140 -0
- package/wizard/lib/curl.mjs +103 -0
- package/wizard/lib/docker-build.mjs +148 -0
- package/wizard/lib/exec.mjs +41 -0
- package/wizard/lib/indexer.mjs +6 -0
- package/wizard/lib/litellm-env.mjs +55 -0
- package/wizard/lib/load-machine-env.mjs +4 -0
- package/wizard/lib/log.mjs +32 -0
- package/wizard/lib/notebooklm-agent-access.mjs +92 -0
- package/wizard/lib/notebooklm.mjs +214 -0
- package/wizard/lib/parse-args.mjs +87 -0
- package/wizard/lib/paths.mjs +39 -0
- package/wizard/lib/pm-config.mjs +371 -0
- package/wizard/lib/ports.mjs +94 -0
- package/wizard/lib/prompts.mjs +85 -0
- package/wizard/lib/provider-wizard/helpers.mjs +98 -0
- package/wizard/lib/provider-wizard/run-steps.mjs +129 -0
- package/wizard/lib/providers/registry.mjs +262 -0
- package/wizard/lib/run-clack.mjs +108 -0
- package/wizard/lib/run-wizard.mjs +750 -0
- package/wizard/lib/validate-setup.mjs +62 -0
- package/wizard/lib/wizard-action.mjs +34 -0
- package/wizard/lib/wizard-conf.mjs +71 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Compact symbol outlines for source files, parsed in-process with tree-sitter (tree-sitter-language-pack)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from tree_sitter_language_pack import PackConfig, ProcessConfig, get_parser, init, process
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
SIGNATURE_LIMIT = 160
|
|
11
|
+
DOC_LIMIT = 120
|
|
12
|
+
MAX_OUTLINE_LINES = 150
|
|
13
|
+
MAX_IMPORTS = 60
|
|
14
|
+
GRAMMAR_DIR_ENV = "TREE_SITTER_GRAMMAR_DIR"
|
|
15
|
+
LANGUAGE_BY_SUFFIX = {
|
|
16
|
+
".py": "python",
|
|
17
|
+
".js": "javascript",
|
|
18
|
+
".jsx": "javascript",
|
|
19
|
+
".ts": "typescript",
|
|
20
|
+
".tsx": "tsx",
|
|
21
|
+
".go": "go",
|
|
22
|
+
".rs": "rust",
|
|
23
|
+
".java": "java",
|
|
24
|
+
".c": "c",
|
|
25
|
+
".h": "c",
|
|
26
|
+
".cc": "cpp",
|
|
27
|
+
".cpp": "cpp",
|
|
28
|
+
".hpp": "cpp",
|
|
29
|
+
".cs": "csharp",
|
|
30
|
+
".rb": "ruby",
|
|
31
|
+
".lua": "lua",
|
|
32
|
+
".sh": "bash",
|
|
33
|
+
".php": "php",
|
|
34
|
+
}
|
|
35
|
+
# The pack's structure extraction does not report imports for these grammars; match their import lines instead.
|
|
36
|
+
FALLBACK_IMPORTS = {
|
|
37
|
+
"c": re.compile(r"^\s*#\s*include\b"),
|
|
38
|
+
"cpp": re.compile(r"^\s*#\s*include\b"),
|
|
39
|
+
"csharp": re.compile(r"^\s*using\s+[\w.]+\s*;"),
|
|
40
|
+
"ruby": re.compile(r"^\s*require(_relative)?\b"),
|
|
41
|
+
"lua": re.compile(r"^\s*(local\s+[\w, ]+=\s*)?require\b"),
|
|
42
|
+
"php": re.compile(r"^\s*use\s+[\w\\ ,{}]+;"),
|
|
43
|
+
}
|
|
44
|
+
# Decorators and attributes sit above the definition; the signature is the first line after them.
|
|
45
|
+
ATTRIBUTE_PREFIXES = ("@", "#[", "[")
|
|
46
|
+
DOC_QUOTES = ('"""', "'''")
|
|
47
|
+
COMMENT_MARKERS = re.compile(r"^\s*(///<|///|//!|//|/\*\*|/\*!|/\*|\*|#|--)\s*")
|
|
48
|
+
|
|
49
|
+
_ready = False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def language_for(path: str) -> str | None:
|
|
53
|
+
return LANGUAGE_BY_SUFFIX.get(Path(path).suffix.lower())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _load(language: str) -> None:
|
|
57
|
+
"""Load the grammar. Raises when it is missing, so a broken image fails the sync instead of emptying code docs."""
|
|
58
|
+
global _ready
|
|
59
|
+
if not _ready:
|
|
60
|
+
init(PackConfig(cache_dir=os.environ.get(GRAMMAR_DIR_ENV) or None))
|
|
61
|
+
_ready = True
|
|
62
|
+
get_parser(language)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _clip(text: str, limit: int = SIGNATURE_LIMIT) -> str:
|
|
66
|
+
return text if len(text) <= limit else text[: limit - 3] + "..."
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _one_line(text: str) -> str:
|
|
70
|
+
return " ".join(text.split())
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _signature(item, lines: list[str]) -> str:
|
|
74
|
+
for row in range(item.span.start_line, min(item.span.end_line, len(lines) - 1) + 1):
|
|
75
|
+
text = lines[row].strip()
|
|
76
|
+
if text and not text.startswith(ATTRIBUTE_PREFIXES):
|
|
77
|
+
return _clip(text)
|
|
78
|
+
return _clip(f"{item.kind} {item.name or ''}".strip())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _doc(item, lines: list[str], language: str) -> str:
|
|
82
|
+
if language == "python" and item.body_span is not None:
|
|
83
|
+
# Python docstrings are the first statement of the body; the pack does not extract them.
|
|
84
|
+
for row in range(item.body_span.start_line, min(item.body_span.start_line + 2, len(lines))):
|
|
85
|
+
text = lines[row].strip()
|
|
86
|
+
if not text.startswith(DOC_QUOTES):
|
|
87
|
+
return ""
|
|
88
|
+
cleaned = text[3:].rstrip("\"'").strip()
|
|
89
|
+
if cleaned:
|
|
90
|
+
return _clip(cleaned, DOC_LIMIT)
|
|
91
|
+
return ""
|
|
92
|
+
for raw in (item.doc_comment or "").splitlines():
|
|
93
|
+
cleaned = COMMENT_MARKERS.sub("", raw).strip().rstrip("*/").strip()
|
|
94
|
+
if cleaned:
|
|
95
|
+
return _clip(cleaned, DOC_LIMIT)
|
|
96
|
+
return ""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _imports(result, language: str, lines: list[str]) -> list[str]:
|
|
100
|
+
found = [_clip(_one_line(item.source)) for item in result.imports]
|
|
101
|
+
if not found and language in FALLBACK_IMPORTS:
|
|
102
|
+
found = [_clip(_one_line(line)) for line in lines if FALLBACK_IMPORTS[language].match(line)]
|
|
103
|
+
return list(dict.fromkeys(item for item in found if item))[:MAX_IMPORTS]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _symbol_lines(items, lines: list[str], language: str, depth: int, out: list[str], rows: set[int]) -> None:
|
|
107
|
+
indent = " " * depth
|
|
108
|
+
for item in items:
|
|
109
|
+
if len(out) >= MAX_OUTLINE_LINES:
|
|
110
|
+
return
|
|
111
|
+
# A one-line member shares its parent's line; listing that line twice adds nothing.
|
|
112
|
+
if item.span.start_line not in rows:
|
|
113
|
+
rows.add(item.span.start_line)
|
|
114
|
+
out.append(f"{indent}- L{item.span.start_line + 1} {_signature(item, lines)}")
|
|
115
|
+
doc = _doc(item, lines, language)
|
|
116
|
+
if doc:
|
|
117
|
+
out.append(f"{indent} doc: {doc}")
|
|
118
|
+
_symbol_lines(item.children, lines, language, depth + 1, out, rows)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def outline(path: str, data: bytes) -> str | None:
|
|
122
|
+
"""Return a text outline of one source file, or None when the suffix is unsupported or the file does not parse."""
|
|
123
|
+
language = language_for(path)
|
|
124
|
+
if language is None:
|
|
125
|
+
return None
|
|
126
|
+
try:
|
|
127
|
+
source = data.decode("utf-8")
|
|
128
|
+
except UnicodeDecodeError:
|
|
129
|
+
return None
|
|
130
|
+
_load(language)
|
|
131
|
+
try:
|
|
132
|
+
result = process(source, ProcessConfig(language=language, structure=True, imports=True, exports=False))
|
|
133
|
+
except Exception:
|
|
134
|
+
return None
|
|
135
|
+
lines = source.split("\n")
|
|
136
|
+
imports = _imports(result, language, lines)
|
|
137
|
+
symbols: list[str] = []
|
|
138
|
+
_symbol_lines(result.structure, lines, language, 0, symbols, set())
|
|
139
|
+
if not symbols and not imports:
|
|
140
|
+
return None
|
|
141
|
+
body = [f"File: {path}", f"Language: {language}"]
|
|
142
|
+
if imports:
|
|
143
|
+
body += ["Imports:"] + [f"- {item}" for item in imports]
|
|
144
|
+
if symbols:
|
|
145
|
+
body += ["Symbols:"] + symbols
|
|
146
|
+
return "\n".join(body)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fetch RSS and OpenAlex query results into markdown under feeds.out_dir for the indexer."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import urllib.request
|
|
12
|
+
import xml.etree.ElementTree as ET
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import index_config
|
|
16
|
+
|
|
17
|
+
USER_AGENT = "openmem-feed-sync/1.0"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def slugify(name: str) -> str:
|
|
21
|
+
return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") or "feed"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def fetch_bytes(url: str, timeout: int = 60) -> bytes:
|
|
25
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
26
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
27
|
+
return resp.read()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def write_markdown(out_dir: Path, bank_slug: str, title: str, body: str, source_url: str) -> Path:
|
|
31
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
digest = hashlib.sha256(source_url.encode()).hexdigest()[:12]
|
|
33
|
+
safe = slugify(title)[:60] or digest
|
|
34
|
+
path = out_dir / f"{bank_slug}--{safe}--{digest}.md"
|
|
35
|
+
text = f"---\nsource_url: {source_url}\nfeed_bank: feed::{bank_slug}\n---\n\n# {title}\n\n{body.strip()}\n"
|
|
36
|
+
path.write_text(text, encoding="utf-8")
|
|
37
|
+
return path
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def sync_rss(url: str, out_dir: Path, slug: str | None = None) -> int:
|
|
41
|
+
bank_slug = slug or slugify(urllib.parse.urlparse(url).path or "rss")
|
|
42
|
+
raw = fetch_bytes(url)
|
|
43
|
+
root = ET.fromstring(raw)
|
|
44
|
+
channel = root.find("channel") or root
|
|
45
|
+
items = channel.findall("item") if channel is not None else root.findall(".//item")
|
|
46
|
+
count = 0
|
|
47
|
+
for item in items[:50]:
|
|
48
|
+
title = (item.findtext("title") or "untitled").strip()
|
|
49
|
+
link = (item.findtext("link") or "").strip()
|
|
50
|
+
desc = (item.findtext("description") or item.findtext("{http://www.w3.org/2005/Atom}summary") or "").strip()
|
|
51
|
+
desc = re.sub(r"<[^>]+>", " ", desc)
|
|
52
|
+
if not link:
|
|
53
|
+
continue
|
|
54
|
+
write_markdown(out_dir, bank_slug, title, desc or title, link)
|
|
55
|
+
count += 1
|
|
56
|
+
return count
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def sync_openalex(query_url: str, out_dir: Path, slug: str | None = None) -> int:
|
|
60
|
+
bank_slug = slug or "openalex"
|
|
61
|
+
raw = fetch_bytes(query_url)
|
|
62
|
+
data = json.loads(raw.decode())
|
|
63
|
+
results = data.get("results") or []
|
|
64
|
+
count = 0
|
|
65
|
+
for work in results[:40]:
|
|
66
|
+
title = work.get("title") or work.get("display_name") or "work"
|
|
67
|
+
wid = work.get("id") or ""
|
|
68
|
+
abstract = work.get("abstract_inverted_index")
|
|
69
|
+
body = title
|
|
70
|
+
if isinstance(abstract, dict):
|
|
71
|
+
words = sorted(abstract.items(), key=lambda kv: kv[1][0] if kv[1] else 0)
|
|
72
|
+
body = " ".join(w for w, _ in words)
|
|
73
|
+
doi = (work.get("doi") or wid) or title
|
|
74
|
+
write_markdown(out_dir, bank_slug, title, body, str(doi))
|
|
75
|
+
count += 1
|
|
76
|
+
return count
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def run(config: index_config.Config | None = None) -> int:
|
|
80
|
+
config = config or index_config.load()
|
|
81
|
+
feeds = getattr(config, "feeds_out_dir", None)
|
|
82
|
+
if feeds is None:
|
|
83
|
+
print("feeds: nothing configured (feeds.rss / feeds.openalex_queries empty)", file=sys.stderr)
|
|
84
|
+
return 0
|
|
85
|
+
out_dir = feeds
|
|
86
|
+
total = 0
|
|
87
|
+
for entry in config.feeds_rss:
|
|
88
|
+
url = entry if isinstance(entry, str) else entry.get("url", "")
|
|
89
|
+
slug = None if isinstance(entry, str) else entry.get("slug")
|
|
90
|
+
if not url:
|
|
91
|
+
continue
|
|
92
|
+
n = sync_rss(url, out_dir, slug)
|
|
93
|
+
print(f"rss {url}: {n} items -> {out_dir}")
|
|
94
|
+
total += n
|
|
95
|
+
for entry in config.feeds_openalex_queries:
|
|
96
|
+
url = entry if isinstance(entry, str) else entry.get("url", "")
|
|
97
|
+
slug = None if isinstance(entry, str) else entry.get("slug")
|
|
98
|
+
if not url:
|
|
99
|
+
continue
|
|
100
|
+
n = sync_openalex(url, out_dir, slug)
|
|
101
|
+
print(f"openalex {url}: {n} works -> {out_dir}")
|
|
102
|
+
total += n
|
|
103
|
+
return 0 if total >= 0 else 1
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
raise SystemExit(run())
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Load and validate the project indexer config (config/index.yml).
|
|
2
|
+
|
|
3
|
+
Lookup, first hit wins: --config PATH, $HINDSIGHT_INDEX_CONFIG, ~/.config/hindsight/index.yml, then the shipped default.
|
|
4
|
+
A file found this way replaces the shipped default entirely. A key the file omits takes its built-in value (DEFAULT_DATA).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import fnmatch
|
|
8
|
+
import os
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
ENV_VAR = "HINDSIGHT_INDEX_CONFIG"
|
|
17
|
+
DEFAULT_PATH = Path(__file__).resolve().parent.parent / "config" / "index.yml"
|
|
18
|
+
USER_PATH = "~/.config/hindsight/index.yml"
|
|
19
|
+
TOP_LEVEL_KEYS = (
|
|
20
|
+
"roots",
|
|
21
|
+
"repos",
|
|
22
|
+
"vaults",
|
|
23
|
+
"notes",
|
|
24
|
+
"papers",
|
|
25
|
+
"bookmarks",
|
|
26
|
+
"feeds",
|
|
27
|
+
"exclude",
|
|
28
|
+
"banks",
|
|
29
|
+
"max_file_kb",
|
|
30
|
+
"skip_dirs",
|
|
31
|
+
"poll_seconds",
|
|
32
|
+
"vault_quiet_seconds",
|
|
33
|
+
"relist_seconds",
|
|
34
|
+
)
|
|
35
|
+
VAULT_KEYS = ("auto", "paths")
|
|
36
|
+
PATH_LIST_KEYS = ("paths",)
|
|
37
|
+
FEEDS_KEYS = ("out_dir", "rss", "openalex_queries")
|
|
38
|
+
ROOT_KEYS = ("path", "depth")
|
|
39
|
+
DEFAULT_SKIP_DIRS = [".cache", ".codegraph", ".decision", ".git", ".next", ".tasks", ".venv", "build", "dist", "node_modules", "target", "vendor"]
|
|
40
|
+
DEFAULT_DATA = {
|
|
41
|
+
"roots": [{"path": "~", "depth": 3}],
|
|
42
|
+
"repos": ["~/.config/nvim"],
|
|
43
|
+
"vaults": {"auto": True, "paths": []},
|
|
44
|
+
"notes": {"paths": []},
|
|
45
|
+
"papers": {"paths": []},
|
|
46
|
+
"bookmarks": {"paths": []},
|
|
47
|
+
"feeds": {"out_dir": "~/.local/share/personal-memory/feeds", "rss": [], "openalex_queries": []},
|
|
48
|
+
"exclude": [],
|
|
49
|
+
"banks": {"~/Documents/sample-project": "coding-agent::documents-sample-project"},
|
|
50
|
+
"max_file_kb": 128,
|
|
51
|
+
"skip_dirs": DEFAULT_SKIP_DIRS,
|
|
52
|
+
"poll_seconds": 60,
|
|
53
|
+
"vault_quiet_seconds": 30,
|
|
54
|
+
"relist_seconds": 600,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ConfigError(ValueError):
|
|
59
|
+
"""A config file that cannot be read or fails validation. The message names the file and the key."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Root:
|
|
64
|
+
path: Path
|
|
65
|
+
depth: int
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Config:
|
|
70
|
+
roots: tuple[Root, ...]
|
|
71
|
+
repos: tuple[Path, ...]
|
|
72
|
+
vault_auto: bool
|
|
73
|
+
vault_paths: tuple[Path, ...]
|
|
74
|
+
notes_paths: tuple[Path, ...]
|
|
75
|
+
papers_paths: tuple[Path, ...]
|
|
76
|
+
bookmarks_paths: tuple[Path, ...]
|
|
77
|
+
feeds_out_dir: Path | None
|
|
78
|
+
feeds_rss: tuple[object, ...]
|
|
79
|
+
feeds_openalex_queries: tuple[object, ...]
|
|
80
|
+
exclude: tuple[str, ...]
|
|
81
|
+
banks: tuple[tuple[Path, str], ...] # longest prefix first
|
|
82
|
+
max_file_kb: int
|
|
83
|
+
skip_dirs: frozenset[str]
|
|
84
|
+
poll_seconds: int
|
|
85
|
+
vault_quiet_seconds: int
|
|
86
|
+
relist_seconds: int
|
|
87
|
+
source: str = field(default="built-in", compare=False)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def max_file_bytes(self) -> int:
|
|
91
|
+
return self.max_file_kb * 1000
|
|
92
|
+
|
|
93
|
+
def bank_for(self, path: Path) -> str | None:
|
|
94
|
+
for prefix, bank in self.banks:
|
|
95
|
+
if path == prefix or prefix in path.parents:
|
|
96
|
+
return bank
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
def is_excluded(self, path: Path) -> bool:
|
|
100
|
+
return any(fnmatch.fnmatchcase(str(path), pattern) for pattern in self.exclude)
|
|
101
|
+
|
|
102
|
+
def markdown_sources(self) -> tuple[tuple[str, Path], ...]:
|
|
103
|
+
"""(kind, path) for watched markdown trees: notes, papers, bookmarks, feed cache."""
|
|
104
|
+
out: list[tuple[str, Path]] = []
|
|
105
|
+
for kind, paths in (
|
|
106
|
+
("notes", self.notes_paths),
|
|
107
|
+
("papers", self.papers_paths),
|
|
108
|
+
("bookmarks", self.bookmarks_paths),
|
|
109
|
+
):
|
|
110
|
+
for path in paths:
|
|
111
|
+
out.append((kind, path))
|
|
112
|
+
if self.feeds_out_dir is not None:
|
|
113
|
+
out.append(("feed", self.feeds_out_dir))
|
|
114
|
+
return tuple(out)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _kind(value: object) -> str:
|
|
118
|
+
if value is None:
|
|
119
|
+
return "nothing (null)"
|
|
120
|
+
if isinstance(value, bool):
|
|
121
|
+
return "a boolean"
|
|
122
|
+
if isinstance(value, int):
|
|
123
|
+
return "an integer"
|
|
124
|
+
if isinstance(value, float):
|
|
125
|
+
return "a number"
|
|
126
|
+
if isinstance(value, str):
|
|
127
|
+
return "a string"
|
|
128
|
+
if isinstance(value, list):
|
|
129
|
+
return "a list"
|
|
130
|
+
if isinstance(value, dict):
|
|
131
|
+
return "a mapping"
|
|
132
|
+
return type(value).__name__
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _int(source: str, key: str, value: object, minimum: int) -> int:
|
|
136
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
137
|
+
raise ConfigError(f"{source}: {key}: expected an integer, got {_kind(value)}")
|
|
138
|
+
if value < minimum:
|
|
139
|
+
raise ConfigError(f"{source}: {key}: must be at least {minimum}, got {value}")
|
|
140
|
+
return value
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _bool(source: str, key: str, value: object) -> bool:
|
|
144
|
+
if not isinstance(value, bool):
|
|
145
|
+
raise ConfigError(f"{source}: {key}: expected true or false, got {_kind(value)}")
|
|
146
|
+
return value
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _text(source: str, key: str, value: object) -> str:
|
|
150
|
+
if not isinstance(value, str) or not value.strip():
|
|
151
|
+
raise ConfigError(f"{source}: {key}: expected a non-empty string, got {_kind(value)}")
|
|
152
|
+
return value
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _list(source: str, key: str, value: object) -> list:
|
|
156
|
+
if not isinstance(value, list):
|
|
157
|
+
raise ConfigError(f"{source}: {key}: expected a list, got {_kind(value)}")
|
|
158
|
+
return value
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _mapping(source: str, key: str, value: object, allowed: tuple[str, ...]) -> dict:
|
|
162
|
+
if not isinstance(value, dict):
|
|
163
|
+
raise ConfigError(f"{source}: {key}: expected a mapping, got {_kind(value)}")
|
|
164
|
+
unknown = [str(name) for name in value if name not in allowed]
|
|
165
|
+
if unknown:
|
|
166
|
+
raise ConfigError(f"{source}: {key}: unknown key(s) {', '.join(unknown)}; allowed: {', '.join(allowed)}")
|
|
167
|
+
return value
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _abs_path(source: str, key: str, text: str) -> Path:
|
|
171
|
+
path = Path(text).expanduser()
|
|
172
|
+
if not path.is_absolute():
|
|
173
|
+
raise ConfigError(f"{source}: {key}: expected an absolute path or one starting with ~, got {text!r}")
|
|
174
|
+
return path
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _roots(source: str, value: object) -> tuple[Root, ...]:
|
|
178
|
+
roots = []
|
|
179
|
+
for index, item in enumerate(_list(source, "roots", value)):
|
|
180
|
+
key = f"roots[{index}]"
|
|
181
|
+
entry = _mapping(source, key, item, ROOT_KEYS)
|
|
182
|
+
if "path" not in entry:
|
|
183
|
+
raise ConfigError(f"{source}: {key}: missing required key 'path'")
|
|
184
|
+
# A bare ~ is YAML null, so null means home.
|
|
185
|
+
text = "~" if entry["path"] is None else _text(source, f"{key}.path", entry["path"])
|
|
186
|
+
depth = _int(source, f"{key}.depth", entry.get("depth", 3), 1)
|
|
187
|
+
roots.append(Root(_abs_path(source, f"{key}.path", text), depth))
|
|
188
|
+
return tuple(roots)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _paths(source: str, key: str, value: object) -> tuple[Path, ...]:
|
|
192
|
+
return tuple(_abs_path(source, f"{key}[{index}]", _text(source, f"{key}[{index}]", item)) for index, item in enumerate(_list(source, key, value)))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _banks(source: str, value: object) -> tuple[tuple[Path, str], ...]:
|
|
196
|
+
if not isinstance(value, dict):
|
|
197
|
+
raise ConfigError(f"{source}: banks: expected a mapping of path to bank id, got {_kind(value)}")
|
|
198
|
+
pairs = []
|
|
199
|
+
for prefix, bank in value.items():
|
|
200
|
+
path = _abs_path(source, f"banks[{prefix}]", _text(source, "banks key", prefix))
|
|
201
|
+
pairs.append((path, _text(source, f"banks[{prefix}]", bank)))
|
|
202
|
+
return tuple(sorted(pairs, key=lambda pair: len(pair[0].parts), reverse=True))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _exclude(source: str, value: object) -> tuple[str, ...]:
|
|
206
|
+
patterns = []
|
|
207
|
+
for index, item in enumerate(_list(source, "exclude", value)):
|
|
208
|
+
pattern = _text(source, f"exclude[{index}]", item)
|
|
209
|
+
if not pattern.startswith(("/", "~")):
|
|
210
|
+
raise ConfigError(f"{source}: exclude[{index}]: expected a glob on an absolute path, got {pattern!r}")
|
|
211
|
+
patterns.append(os.path.expanduser(pattern))
|
|
212
|
+
return tuple(patterns)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _skip_dirs(source: str, value: object) -> frozenset[str]:
|
|
216
|
+
names = []
|
|
217
|
+
for index, item in enumerate(_list(source, "skip_dirs", value)):
|
|
218
|
+
name = _text(source, f"skip_dirs[{index}]", item)
|
|
219
|
+
if "/" in name:
|
|
220
|
+
raise ConfigError(f"{source}: skip_dirs[{index}]: expected a directory name, not a path, got {name!r}")
|
|
221
|
+
names.append(name)
|
|
222
|
+
return frozenset(names)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def parse(data: object, source: str = "built-in") -> Config:
|
|
226
|
+
"""Validate parsed YAML. None (an empty file) and omitted keys take the DEFAULT_DATA values."""
|
|
227
|
+
top = _mapping(source, "top level", DEFAULT_DATA if data is None else data, TOP_LEVEL_KEYS)
|
|
228
|
+
merged = {**DEFAULT_DATA, **top}
|
|
229
|
+
vaults = _mapping(source, "vaults", merged["vaults"], VAULT_KEYS)
|
|
230
|
+
notes = _mapping(source, "notes", merged["notes"], PATH_LIST_KEYS)
|
|
231
|
+
papers = _mapping(source, "papers", merged["papers"], PATH_LIST_KEYS)
|
|
232
|
+
bookmarks = _mapping(source, "bookmarks", merged["bookmarks"], PATH_LIST_KEYS)
|
|
233
|
+
feeds_raw = _mapping(source, "feeds", merged.get("feeds") or DEFAULT_DATA["feeds"], FEEDS_KEYS)
|
|
234
|
+
feeds_out = _paths(source, "feeds.out_dir", [feeds_raw.get("out_dir") or DEFAULT_DATA["feeds"]["out_dir"]])[0]
|
|
235
|
+
feeds_rss = tuple(_list(source, "feeds.rss", feeds_raw.get("rss") or []))
|
|
236
|
+
feeds_oa = tuple(_list(source, "feeds.openalex_queries", feeds_raw.get("openalex_queries") or []))
|
|
237
|
+
has_feeds = bool(feeds_rss or feeds_oa)
|
|
238
|
+
banks = merged["banks"]
|
|
239
|
+
return Config(
|
|
240
|
+
roots=_roots(source, merged["roots"]),
|
|
241
|
+
repos=_paths(source, "repos", merged["repos"]),
|
|
242
|
+
vault_auto=_bool(source, "vaults.auto", vaults.get("auto", True)),
|
|
243
|
+
vault_paths=_paths(source, "vaults.paths", vaults.get("paths", [])),
|
|
244
|
+
notes_paths=_paths(source, "notes.paths", notes.get("paths", [])),
|
|
245
|
+
papers_paths=_paths(source, "papers.paths", papers.get("paths", [])),
|
|
246
|
+
bookmarks_paths=_paths(source, "bookmarks.paths", bookmarks.get("paths", [])),
|
|
247
|
+
feeds_out_dir=feeds_out if has_feeds else None,
|
|
248
|
+
feeds_rss=feeds_rss,
|
|
249
|
+
feeds_openalex_queries=feeds_oa,
|
|
250
|
+
exclude=_exclude(source, merged["exclude"]),
|
|
251
|
+
banks=_banks(source, banks),
|
|
252
|
+
max_file_kb=_int(source, "max_file_kb", merged["max_file_kb"], 1),
|
|
253
|
+
skip_dirs=_skip_dirs(source, merged["skip_dirs"]),
|
|
254
|
+
poll_seconds=_int(source, "poll_seconds", merged["poll_seconds"], 1),
|
|
255
|
+
vault_quiet_seconds=_int(source, "vault_quiet_seconds", merged["vault_quiet_seconds"], 0),
|
|
256
|
+
relist_seconds=_int(source, "relist_seconds", merged["relist_seconds"], 1),
|
|
257
|
+
source=source,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def read(path: Path) -> object:
|
|
262
|
+
try:
|
|
263
|
+
text = path.read_text(encoding="utf-8")
|
|
264
|
+
except OSError as error:
|
|
265
|
+
raise ConfigError(f"{path}: cannot read config: {error.strerror or error}") from error
|
|
266
|
+
try:
|
|
267
|
+
return yaml.safe_load(text)
|
|
268
|
+
except yaml.YAMLError as error:
|
|
269
|
+
raise ConfigError(f"{path}: invalid YAML: {error}") from error
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _user_path() -> Path | None:
|
|
273
|
+
"""The per-machine file, or None when absent or not visible (for example, inside a masked directory)."""
|
|
274
|
+
user = Path(USER_PATH).expanduser()
|
|
275
|
+
try:
|
|
276
|
+
return user if user.is_file() else None
|
|
277
|
+
except OSError:
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def load(explicit: str | Path | None = None, environ: Mapping[str, str] = os.environ) -> Config:
|
|
282
|
+
if explicit:
|
|
283
|
+
path = Path(explicit).expanduser()
|
|
284
|
+
elif environ.get(ENV_VAR):
|
|
285
|
+
path = Path(environ[ENV_VAR]).expanduser()
|
|
286
|
+
else:
|
|
287
|
+
path = _user_path() or DEFAULT_PATH
|
|
288
|
+
if not path.is_file():
|
|
289
|
+
raise ConfigError(f"{path}: config file not found")
|
|
290
|
+
return parse(read(path), source=str(path))
|