abstractnote 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.
- abstractnote/__init__.py +18 -0
- abstractnote/cli.py +240 -0
- abstractnote/flows/__init__.py +2 -0
- abstractnote/flows/build.py +970 -0
- abstractnote/flows/bundle.py +62 -0
- abstractnote/flows/contract.py +88 -0
- abstractnote/flows/graph.py +540 -0
- abstractnote/flows/json/note-capture.json +2280 -0
- abstractnote/flows/json/note-drain.json +2012 -0
- abstractnote/flows/json/note-mine.json +2392 -0
- abstractnote/flows/json/note-process.json +1853 -0
- abstractnote/flows/json/note-recall.json +1876 -0
- abstractnote/flows/json/note-reflect.json +2057 -0
- abstractnote/flows/prompts_fallback.py +119 -0
- abstractnote/flows/runner.py +320 -0
- abstractnote/flows/schemas_fallback.py +175 -0
- abstractnote/gateway/__init__.py +24 -0
- abstractnote/gateway/appspec.py +10 -0
- abstractnote/gateway/router.py +571 -0
- abstractnote/gateway/tools.py +74 -0
- abstractnote/ids.py +177 -0
- abstractnote/importers/__init__.py +142 -0
- abstractnote/importers/__main__.py +3 -0
- abstractnote/importers/agora.py +203 -0
- abstractnote/importers/claude_sessions.py +415 -0
- abstractnote/importers/cli.py +113 -0
- abstractnote/importers/files.py +72 -0
- abstractnote/importers/markdown.py +165 -0
- abstractnote/ingest/__init__.py +7 -0
- abstractnote/ingest/blobs.py +47 -0
- abstractnote/ingest/capture.py +265 -0
- abstractnote/ingest/extract.py +118 -0
- abstractnote/ingest/normalise.py +95 -0
- abstractnote/model.py +229 -0
- abstractnote/process/__init__.py +5 -0
- abstractnote/process/context.py +218 -0
- abstractnote/process/merge.py +60 -0
- abstractnote/process/prompts/__init__.py +39 -0
- abstractnote/process/prompts/extract.md +30 -0
- abstractnote/process/prompts/link.md +32 -0
- abstractnote/process/prompts/mine_dossier.md +13 -0
- abstractnote/process/prompts/mine_findings.md +13 -0
- abstractnote/process/prompts/mine_plan.md +13 -0
- abstractnote/process/prompts/quick_filing.md +23 -0
- abstractnote/process/prompts/reflect.md +10 -0
- abstractnote/process/prompts/synthesize.md +17 -0
- abstractnote/process/resolve.py +96 -0
- abstractnote/process/revise.py +410 -0
- abstractnote/process/schemas.py +377 -0
- abstractnote/process/vocabulary.py +68 -0
- abstractnote/recall/__init__.py +1 -0
- abstractnote/recall/changes.py +191 -0
- abstractnote/recall/expand.py +379 -0
- abstractnote/recall/insights.py +62 -0
- abstractnote/recall/reflect.py +202 -0
- abstractnote/recall/review.py +191 -0
- abstractnote/recall/search.py +205 -0
- abstractnote/recall/shapes.py +52 -0
- abstractnote/recall/then_now.py +100 -0
- abstractnote/store/__init__.py +12 -0
- abstractnote/store/archive.py +166 -0
- abstractnote/store/embeddings.py +275 -0
- abstractnote/store/kg.py +219 -0
- abstractnote/store/notebook.py +829 -0
- abstractnote/store/notes.py +1190 -0
- abstractnote/store/queue.py +159 -0
- abstractnote/store/schema.py +348 -0
- abstractnote/store/vector_index.py +88 -0
- abstractnote/tools/__init__.py +215 -0
- abstractnote/tools/capture.py +121 -0
- abstractnote/tools/context.py +19 -0
- abstractnote/tools/process.py +22 -0
- abstractnote/tools/queue.py +23 -0
- abstractnote/tools/recall.py +64 -0
- abstractnote-0.1.0.dist-info/METADATA +145 -0
- abstractnote-0.1.0.dist-info/RECORD +80 -0
- abstractnote-0.1.0.dist-info/WHEEL +5 -0
- abstractnote-0.1.0.dist-info/entry_points.txt +2 -0
- abstractnote-0.1.0.dist-info/licenses/LICENSE +21 -0
- abstractnote-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Note tools: plane-bound callables for flows (``tool_calls`` nodes) and agents (``allowed_tools``).
|
|
2
|
+
|
|
3
|
+
``bind(notebook_dir, ...)`` returns ``{tool_name: callable}``; each callable is
|
|
4
|
+
decorated with AbstractCore's ``@tool``, so ``MappingToolExecutor.from_tools``
|
|
5
|
+
and the runtime's ``tool_calls`` nodes use them directly and their
|
|
6
|
+
``ToolDefinition.parameters`` describe the accepted arguments. Every tool takes
|
|
7
|
+
JSON-friendly keyword arguments, returns a dict, and raises ``ValueError`` with
|
|
8
|
+
a plain sentence on bad input. ``TOOL_NAMES`` is the auto-approve list.
|
|
9
|
+
|
|
10
|
+
The notebook is opened on the first call, so binding is cheap and creates no
|
|
11
|
+
files.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import threading
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
from .context import ArtifactReader, ToolContext
|
|
21
|
+
|
|
22
|
+
TOOL_NAMES: List[str] = [
|
|
23
|
+
"note_capture", "note_file", "note_context", "note_resolve_candidates", "note_apply_revision",
|
|
24
|
+
"note_queue_lease", "note_queue_ack", "note_queue_stats", "note_search", "note_expand",
|
|
25
|
+
"note_changes", "note_entity", "note_timeline", "note_open_loops", "note_contradictions",
|
|
26
|
+
"note_resurface", "note_anniversaries", "note_insight_write",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _LazyContext(ToolContext):
|
|
31
|
+
"""A ToolContext whose notebook opens on first use."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, notebook_dir: Path, embedder: Any, **kwargs: Any) -> None:
|
|
34
|
+
super().__init__(nb=None, **kwargs)
|
|
35
|
+
self._dir = Path(notebook_dir)
|
|
36
|
+
self._embedder = embedder
|
|
37
|
+
self._open_lock = threading.Lock()
|
|
38
|
+
|
|
39
|
+
def __getattribute__(self, name: str) -> Any:
|
|
40
|
+
if name == "nb":
|
|
41
|
+
nb = object.__getattribute__(self, "__dict__").get("nb")
|
|
42
|
+
if nb is None:
|
|
43
|
+
with object.__getattribute__(self, "_open_lock"):
|
|
44
|
+
nb = object.__getattribute__(self, "__dict__").get("nb")
|
|
45
|
+
if nb is None:
|
|
46
|
+
from ..store.notebook import Notebook
|
|
47
|
+
|
|
48
|
+
nb = Notebook.open(object.__getattribute__(self, "_dir"),
|
|
49
|
+
embedder=object.__getattribute__(self, "_embedder"))
|
|
50
|
+
nb.extractors.update(object.__getattribute__(self, "extractors"))
|
|
51
|
+
object.__getattribute__(self, "__dict__")["nb"] = nb
|
|
52
|
+
return nb
|
|
53
|
+
return object.__getattribute__(self, name)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def bind(notebook_dir: "str | Path", *, embedder: Any = None, artifact_reader: Optional[ArtifactReader] = None,
|
|
57
|
+
extractors: Optional[Dict[str, Any]] = None, allow_paths_under: "str | Path | None" = None,
|
|
58
|
+
account: Optional[str] = None, default_client: str = "agent") -> Dict[str, Callable[..., Any]]:
|
|
59
|
+
"""The note tools bound to one notebook directory.
|
|
60
|
+
|
|
61
|
+
- ``embedder``: the notebook's embedding model (None keeps the notebook's own pin).
|
|
62
|
+
- ``artifact_reader(ref) -> (bytes, mime, filename)``: resolves ``{"$artifact": id}`` attachments.
|
|
63
|
+
- ``extractors``: ``{"vision": fn, "stt": fn}`` for image descriptions and transcripts.
|
|
64
|
+
- ``allow_paths_under``: accept ``{"path": ...}`` attachments inside this folder (headless only).
|
|
65
|
+
- ``account``: stamped as ``source.account`` on every captured note.
|
|
66
|
+
"""
|
|
67
|
+
from abstractcore.tools.core import tool
|
|
68
|
+
|
|
69
|
+
from . import capture as cap
|
|
70
|
+
from . import process as proc
|
|
71
|
+
from . import queue as que
|
|
72
|
+
from . import recall as rec
|
|
73
|
+
|
|
74
|
+
ctx = _LazyContext(Path(notebook_dir).expanduser(), embedder, artifact_reader=artifact_reader,
|
|
75
|
+
allow_paths_under=Path(allow_paths_under).expanduser() if allow_paths_under else None,
|
|
76
|
+
account=account, default_client=default_client, extractors=dict(extractors or {}))
|
|
77
|
+
|
|
78
|
+
@tool(name="note_capture", description="Save a note (text, time, place, attachments) to the notebook now.",
|
|
79
|
+
when_to_use="Whenever something should be remembered; returns the note id, revisits and similar notes.")
|
|
80
|
+
def note_capture(text: str = "", idempotency_key: Optional[str] = None, title: Optional[str] = None,
|
|
81
|
+
occurred_at: Optional[str] = None, location: Optional[Dict[str, Any]] = None,
|
|
82
|
+
source: Optional[Dict[str, Any]] = None, priority: Optional[int] = None,
|
|
83
|
+
attachments: Optional[List[Dict[str, Any]]] = None, session_id: Optional[str] = None,
|
|
84
|
+
about: Optional[Dict[str, Any]] = None, answer: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
85
|
+
"""Save a note to the notebook now (``answer`` = {question, evidence_note_ids}: a saved answer)."""
|
|
86
|
+
return cap.note_capture(ctx, text, idempotency_key, title, occurred_at, location, source, priority,
|
|
87
|
+
attachments, session_id, about, answer)
|
|
88
|
+
|
|
89
|
+
@tool(name="note_file", description="Store the quick filing of a note: title, kind, topics, urgency, language.")
|
|
90
|
+
def note_file(note_id: str, title: Optional[str] = None, kind: Optional[str] = None,
|
|
91
|
+
topics: Optional[List[str]] = None, urgency: Optional[str] = None, language: Optional[str] = None,
|
|
92
|
+
one_line: Optional[str] = None) -> Dict[str, Any]:
|
|
93
|
+
"""Store the quick filing of a note."""
|
|
94
|
+
return cap.note_file(ctx, note_id, title, kind, topics, urgency, language, one_line)
|
|
95
|
+
|
|
96
|
+
@tool(name="note_context", description="What the notebook already knows that is relevant to one note.",
|
|
97
|
+
when_to_use="First step of processing a note: sections, similar notes, candidate entities, open tasks.")
|
|
98
|
+
def note_context(note_id: str, max_chars: Optional[int] = None) -> Dict[str, Any]:
|
|
99
|
+
"""What the notebook already knows that is relevant to one note."""
|
|
100
|
+
return proc.note_context(ctx, note_id, max_chars)
|
|
101
|
+
|
|
102
|
+
@tool(name="note_resolve_candidates", description="Existing entities that each mention of a note may refer to.")
|
|
103
|
+
def note_resolve_candidates(note_id: str, mentions: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
104
|
+
"""Existing entities that each mention may refer to."""
|
|
105
|
+
return proc.note_resolve_candidates(ctx, note_id, mentions)
|
|
106
|
+
|
|
107
|
+
@tool(name="note_apply_revision", description="Apply the extraction and linking decisions of a note to the graph.")
|
|
108
|
+
def note_apply_revision(note_id: str, extraction: Dict[str, Any], linking: Dict[str, Any]) -> Dict[str, Any]:
|
|
109
|
+
"""Apply the extraction and linking decisions of a note to the graph."""
|
|
110
|
+
return proc.note_apply_revision(ctx, note_id, extraction, linking)
|
|
111
|
+
|
|
112
|
+
@tool(name="note_queue_lease", description="Take the next notes to process from the queue, by priority.")
|
|
113
|
+
def note_queue_lease(worker_id: str, max_items: int = 1) -> Dict[str, Any]:
|
|
114
|
+
"""Take the next notes to process from the queue."""
|
|
115
|
+
return que.note_queue_lease(ctx, worker_id, max_items)
|
|
116
|
+
|
|
117
|
+
@tool(name="note_queue_ack", description="Report how processing a leased note went (ok or error).")
|
|
118
|
+
def note_queue_ack(note_id: str, outcome: str, error: Optional[str] = None,
|
|
119
|
+
revision_id: Optional[str] = None) -> Dict[str, Any]:
|
|
120
|
+
"""Report how processing a leased note went."""
|
|
121
|
+
return que.note_queue_ack(ctx, note_id, outcome, error or None, revision_id or None)
|
|
122
|
+
|
|
123
|
+
@tool(name="note_queue_stats", description="How many notes wait, are being processed or failed.")
|
|
124
|
+
def note_queue_stats() -> Dict[str, Any]:
|
|
125
|
+
"""How many notes wait, are being processed or failed."""
|
|
126
|
+
return que.note_queue_stats(ctx)
|
|
127
|
+
|
|
128
|
+
@tool(name="note_search", description="Quick search of the notes, entities and statements (no model).",
|
|
129
|
+
when_to_use="Before a task or to answer a question cheaply; filters by time, kind, entity and topic.")
|
|
130
|
+
def note_search(query: str = "", since: Optional[str] = None, until: Optional[str] = None,
|
|
131
|
+
kinds: Optional[List[str]] = None, entities: Optional[List[str]] = None,
|
|
132
|
+
topics: Optional[List[str]] = None, limit: int = 20, as_of: Optional[str] = None
|
|
133
|
+
) -> Dict[str, Any]:
|
|
134
|
+
"""Quick search of the notes, entities and statements."""
|
|
135
|
+
return rec.note_search(ctx, query, since, until, kinds, entities, topics, limit, as_of)
|
|
136
|
+
|
|
137
|
+
@tool(name="note_expand", description="Entity cards, timelines, relations and then-versus-now around notes or entities.")
|
|
138
|
+
def note_expand(note_ids: Optional[List[str]] = None, entity_ids: Optional[List[str]] = None,
|
|
139
|
+
as_of: Optional[str] = None) -> Dict[str, Any]:
|
|
140
|
+
"""Entity cards, timelines, relations and then-versus-now around notes or entities."""
|
|
141
|
+
return rec.note_expand(ctx, note_ids, entity_ids, as_of)
|
|
142
|
+
|
|
143
|
+
@tool(name="note_changes", description="What the notebook learned since a cursor or a time.",
|
|
144
|
+
when_to_use="First call of a session: new notes and entities, superseded beliefs, closed loops, failures.")
|
|
145
|
+
def note_changes(since: Optional[str] = None, limit: int = 50) -> Dict[str, Any]:
|
|
146
|
+
"""What the notebook learned since a cursor or a time."""
|
|
147
|
+
return rec.note_changes(ctx, since, limit)
|
|
148
|
+
|
|
149
|
+
@tool(name="note_entity", description="The card of one entity as known at a moment.")
|
|
150
|
+
def note_entity(entity_id: str, at: Optional[str] = None) -> Dict[str, Any]:
|
|
151
|
+
"""The card of one entity as known at a moment."""
|
|
152
|
+
return rec.note_entity(ctx, entity_id, at)
|
|
153
|
+
|
|
154
|
+
@tool(name="note_timeline", description="Notebook activity per day, week or month.")
|
|
155
|
+
def note_timeline(since: Optional[str] = None, until: Optional[str] = None,
|
|
156
|
+
granularity: str = "day") -> Dict[str, Any]:
|
|
157
|
+
"""Notebook activity per day, week or month."""
|
|
158
|
+
return rec.note_timeline(ctx, since, until, granularity)
|
|
159
|
+
|
|
160
|
+
@tool(name="note_open_loops", description="Open tasks and unanswered questions that may need attention.")
|
|
161
|
+
def note_open_loops(limit: int = 20, since: Optional[str] = None) -> Dict[str, Any]:
|
|
162
|
+
"""Open tasks and unanswered questions."""
|
|
163
|
+
return rec.note_open_loops(ctx, limit, since)
|
|
164
|
+
|
|
165
|
+
@tool(name="note_contradictions", description="Disputed statements and subjects with several current values.")
|
|
166
|
+
def note_contradictions(limit: int = 20, since: Optional[str] = None) -> Dict[str, Any]:
|
|
167
|
+
"""Disputed statements and subjects with several current values."""
|
|
168
|
+
return rec.note_contradictions(ctx, limit, since)
|
|
169
|
+
|
|
170
|
+
@tool(name="note_resurface", description="Entities and topics gone quiet that relate to recent notes.")
|
|
171
|
+
def note_resurface(limit: int = 20, since: Optional[str] = None) -> Dict[str, Any]:
|
|
172
|
+
"""Entities and topics gone quiet that relate to recent notes."""
|
|
173
|
+
return rec.note_resurface(ctx, limit, since)
|
|
174
|
+
|
|
175
|
+
@tool(name="note_anniversaries", description="Notes from this day in earlier months and years.")
|
|
176
|
+
def note_anniversaries(limit: int = 20, since: Optional[str] = None) -> Dict[str, Any]:
|
|
177
|
+
"""Notes from this day in earlier months and years."""
|
|
178
|
+
return rec.note_anniversaries(ctx, limit, since)
|
|
179
|
+
|
|
180
|
+
@tool(name="note_insight_write", description="Store an insight or a dossier with the notes and entities behind it.")
|
|
181
|
+
def note_insight_write(kind: str, title: str, body: str, evidence_note_ids: Optional[List[str]] = None,
|
|
182
|
+
entity_ids: Optional[List[str]] = None, run_id: Optional[str] = None) -> Dict[str, Any]:
|
|
183
|
+
"""Store an insight or a dossier with its evidence."""
|
|
184
|
+
return rec.note_insight_write(ctx, kind, title, body, evidence_note_ids, entity_ids, run_id)
|
|
185
|
+
|
|
186
|
+
tools = {
|
|
187
|
+
"note_capture": note_capture, "note_file": note_file, "note_context": note_context,
|
|
188
|
+
"note_resolve_candidates": note_resolve_candidates, "note_apply_revision": note_apply_revision,
|
|
189
|
+
"note_queue_lease": note_queue_lease, "note_queue_ack": note_queue_ack, "note_queue_stats": note_queue_stats,
|
|
190
|
+
"note_search": note_search, "note_expand": note_expand, "note_changes": note_changes,
|
|
191
|
+
"note_entity": note_entity, "note_timeline": note_timeline, "note_open_loops": note_open_loops,
|
|
192
|
+
"note_contradictions": note_contradictions, "note_resurface": note_resurface,
|
|
193
|
+
"note_anniversaries": note_anniversaries, "note_insight_write": note_insight_write,
|
|
194
|
+
}
|
|
195
|
+
assert list(tools) == TOOL_NAMES
|
|
196
|
+
return tools
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def bound_notebook(tools: Dict[str, Callable[..., Any]]) -> Any:
|
|
200
|
+
"""The Notebook behind a bound tool set (opens it when needed)."""
|
|
201
|
+
fn = tools["note_search"]
|
|
202
|
+
for cell in getattr(fn, "__closure__", None) or ():
|
|
203
|
+
value = cell.cell_contents
|
|
204
|
+
if isinstance(value, ToolContext):
|
|
205
|
+
return value.nb
|
|
206
|
+
raise ValueError("These tools were not made by abstractnote.tools.bind.")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def tool_specs() -> List[Dict[str, Any]]:
|
|
210
|
+
"""The LLM-facing specs of the note tools (for ``llm_call.tools`` or an agent's catalog)."""
|
|
211
|
+
tools = bind(Path("/nonexistent-abstractnote-spec-only")) # never opened: specs only
|
|
212
|
+
return [tools[name]._tool_definition.to_dict() for name in TOOL_NAMES]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
__all__ = ["TOOL_NAMES", "ToolContext", "bind", "bound_notebook", "tool_specs"]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""``note_capture`` and ``note_file`` (docs/api.md, "Note tools").
|
|
2
|
+
|
|
3
|
+
Attachments are ``{"$artifact": id, "run_id"?: ...}`` gateway references,
|
|
4
|
+
resolved through the injected ``artifact_reader(ref) -> (bytes, mime,
|
|
5
|
+
filename)``, or ``{"path": ...}`` local files, accepted only when the tools
|
|
6
|
+
were bound with ``allow_paths_under=ROOT`` and the resolved path is inside it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from .context import ToolContext
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resolve_attachments(ctx: ToolContext, attachments: Optional[List[Any]]) -> List[Dict[str, Any]]:
|
|
18
|
+
out: List[Dict[str, Any]] = []
|
|
19
|
+
for i, item in enumerate(attachments or []):
|
|
20
|
+
if not isinstance(item, dict):
|
|
21
|
+
raise ValueError(f"Attachment {i + 1} must be an object with $artifact or path.")
|
|
22
|
+
if "$artifact" in item:
|
|
23
|
+
ref = {"$artifact": str(item["$artifact"])}
|
|
24
|
+
if item.get("run_id"):
|
|
25
|
+
ref["run_id"] = str(item["run_id"])
|
|
26
|
+
if ctx.artifact_reader is None:
|
|
27
|
+
raise ValueError("This notebook cannot read gateway attachments: no artifact reader was bound to "
|
|
28
|
+
"the note tools (abstractnote.tools.bind(..., artifact_reader=...)).")
|
|
29
|
+
data, mime, filename = ctx.artifact_reader(ref)
|
|
30
|
+
if not isinstance(data, (bytes, bytearray)):
|
|
31
|
+
raise ValueError(f"The attachment {ref['$artifact']} could not be read.")
|
|
32
|
+
out.append({"data": bytes(data), "mime": item.get("mime") or mime,
|
|
33
|
+
"filename": item.get("filename") or filename,
|
|
34
|
+
"artifact_ref": {"artifact_id": ref["$artifact"], **({"run_id": ref["run_id"]}
|
|
35
|
+
if "run_id" in ref else {})}})
|
|
36
|
+
elif "path" in item:
|
|
37
|
+
if ctx.allow_paths_under is None:
|
|
38
|
+
raise ValueError("Local file paths are not accepted here; upload the file as an attachment instead.")
|
|
39
|
+
root = Path(ctx.allow_paths_under).expanduser().resolve()
|
|
40
|
+
path = Path(str(item["path"])).expanduser()
|
|
41
|
+
if not path.is_absolute():
|
|
42
|
+
path = root / path
|
|
43
|
+
path = path.resolve()
|
|
44
|
+
if path != root and root not in path.parents:
|
|
45
|
+
raise ValueError("That file is outside the folder this notebook may read from.")
|
|
46
|
+
if not path.is_file():
|
|
47
|
+
raise ValueError(f"There is no file at {item['path']}.")
|
|
48
|
+
out.append({"data": path.read_bytes(), "mime": item.get("mime"), "filename": item.get("filename") or path.name,
|
|
49
|
+
"artifact_ref": None})
|
|
50
|
+
else:
|
|
51
|
+
raise ValueError(f"Attachment {i + 1} must have $artifact (or path when reading local files is allowed).")
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def note_capture(ctx: ToolContext, text: str = "", idempotency_key: Optional[str] = None, title: Optional[str] = None,
|
|
56
|
+
occurred_at: Optional[str] = None, location: Optional[Dict[str, Any]] = None,
|
|
57
|
+
source: Optional[Dict[str, Any]] = None, priority: Optional[int] = None,
|
|
58
|
+
attachments: Optional[List[Any]] = None, session_id: Optional[str] = None,
|
|
59
|
+
about: Optional[Dict[str, Any]] = None, answer: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
60
|
+
def blank(v: Any) -> Any: # flows pass "" / {} / [] for unset start pins
|
|
61
|
+
return None if v in ("", {}, []) else v
|
|
62
|
+
|
|
63
|
+
saved_answer = _answer(blank(answer))
|
|
64
|
+
resolved = resolve_attachments(ctx, blank(attachments))
|
|
65
|
+
priority = blank(priority)
|
|
66
|
+
result = ctx.nb.capture(
|
|
67
|
+
text, title=blank(title), occurred_at=blank(occurred_at), location=blank(location), source=blank(source),
|
|
68
|
+
priority=None if priority is None else int(priority), attachments=resolved,
|
|
69
|
+
idempotency_key=blank(idempotency_key), session_id=blank(session_id), account=ctx.account, about=blank(about),
|
|
70
|
+
default_client=ctx.default_client, vision=ctx.extractors.get("vision"), stt=ctx.extractors.get("stt"),
|
|
71
|
+
)
|
|
72
|
+
out = result.model_dump()
|
|
73
|
+
if saved_answer is not None and result.status == "new":
|
|
74
|
+
out["warnings"] = list(out.get("warnings") or []) + _file_answer(ctx.nb, result.note_id, saved_answer)
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _answer(answer: Any) -> Optional[Dict[str, Any]]:
|
|
79
|
+
"""``context.answer = {question, evidence_note_ids}`` (the web app's "Save this answer"), checked."""
|
|
80
|
+
if answer is None:
|
|
81
|
+
return None
|
|
82
|
+
if not isinstance(answer, dict) or not str(answer.get("question") or "").strip():
|
|
83
|
+
raise ValueError("A saved answer needs the question it answers.")
|
|
84
|
+
evidence = answer.get("evidence_note_ids") or []
|
|
85
|
+
if not isinstance(evidence, list) or not all(isinstance(x, str) for x in evidence):
|
|
86
|
+
raise ValueError("The evidence of a saved answer must be a list of note ids.")
|
|
87
|
+
return {"question": str(answer["question"]).strip(), "evidence_note_ids": list(dict.fromkeys(evidence))}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _file_answer(nb: Any, note_id: str, answer: Dict[str, Any]) -> List[str]:
|
|
91
|
+
"""File a saved answer as a ``synthesis`` note linked (``dcterms:references``) to each evidence
|
|
92
|
+
note, the same link ``context.about`` makes; evidence ids outside the notebook are left out."""
|
|
93
|
+
from .. import ids
|
|
94
|
+
from ..store.notes import _j
|
|
95
|
+
|
|
96
|
+
warnings: List[str] = []
|
|
97
|
+
now = ids.utc_now()
|
|
98
|
+
detail = _j({"kind": "answer", "question": answer["question"]})
|
|
99
|
+
with nb.store.tx() as c:
|
|
100
|
+
c.execute("UPDATE notes SET kind='synthesis' WHERE note_id=?", (note_id,))
|
|
101
|
+
for nid in answer["evidence_note_ids"]:
|
|
102
|
+
if nid == note_id or nb.store.note_row(nid) is None:
|
|
103
|
+
warnings.append(f"The evidence note {nid} is not in this notebook; it was left out.")
|
|
104
|
+
continue
|
|
105
|
+
c.execute("INSERT OR IGNORE INTO note_links(src_note_id, dst_note_id, predicate, score, matched_by,"
|
|
106
|
+
" detail_json, created_at) VALUES (?,?,?,1.0,?,?,?)",
|
|
107
|
+
(note_id, nid, "dcterms:references", "answer", detail, now))
|
|
108
|
+
return warnings
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def note_file(ctx: ToolContext, note_id: str, title: Optional[str] = None, kind: Optional[str] = None,
|
|
112
|
+
topics: Optional[List[str]] = None, urgency: Optional[str] = None, language: Optional[str] = None,
|
|
113
|
+
one_line: Optional[str] = None) -> Dict[str, Any]:
|
|
114
|
+
def blank(v: Any) -> Any:
|
|
115
|
+
return None if v in ("", {}, []) else v
|
|
116
|
+
|
|
117
|
+
row = ctx.nb.store.note_row(note_id)
|
|
118
|
+
if row is not None and row["kind"] == "synthesis":
|
|
119
|
+
kind = None # a saved answer stays a synthesis whatever the quick filing proposes
|
|
120
|
+
return ctx.nb.file_note(note_id, title=blank(title), kind=blank(kind), topics=topics or [],
|
|
121
|
+
urgency=blank(urgency), language=blank(language), one_line=blank(one_line))
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""What a bound tool set knows: the notebook, the injected readers and the account."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Callable, Dict, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
ArtifactReader = Callable[[Dict[str, Any]], Tuple[bytes, Optional[str], Optional[str]]]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ToolContext:
|
|
14
|
+
nb: Any # abstractnote.store.notebook.Notebook
|
|
15
|
+
artifact_reader: Optional[ArtifactReader] = None
|
|
16
|
+
allow_paths_under: Optional[Path] = None
|
|
17
|
+
account: Optional[str] = None
|
|
18
|
+
default_client: str = "agent"
|
|
19
|
+
extractors: Dict[str, Any] = field(default_factory=dict)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""``note_context``, ``note_resolve_candidates``, ``note_apply_revision`` (docs/api.md)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from ..process.context import note_context as _context
|
|
8
|
+
from ..process.resolve import resolve_candidates
|
|
9
|
+
from ..process.revise import apply_note_revision
|
|
10
|
+
from .context import ToolContext
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def note_context(ctx: ToolContext, note_id: str, max_chars: Optional[int] = None) -> Dict[str, Any]:
|
|
14
|
+
return _context(ctx.nb, note_id, max_chars)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def note_resolve_candidates(ctx: ToolContext, note_id: str, mentions: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
18
|
+
return resolve_candidates(ctx.nb, note_id, mentions)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def note_apply_revision(ctx: ToolContext, note_id: str, extraction: Any, linking: Dict[str, Any]) -> Dict[str, Any]:
|
|
22
|
+
return apply_note_revision(ctx.nb, note_id, extraction, linking)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""``note_queue_lease``, ``note_queue_ack``, ``note_queue_stats`` (docs/api.md)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
from .context import ToolContext
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def note_queue_lease(ctx: ToolContext, worker_id: str, max_items: int = 1) -> Dict[str, Any]:
|
|
11
|
+
out = ctx.nb.queue.lease(worker_id, max_items)
|
|
12
|
+
return {"items": [{"note_id": i["note_id"], "priority": i["priority"], "attempts": i["attempts"]}
|
|
13
|
+
for i in out["items"]]}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def note_queue_ack(ctx: ToolContext, note_id: str, outcome: str, error: Optional[str] = None,
|
|
17
|
+
revision_id: Optional[str] = None) -> Dict[str, Any]:
|
|
18
|
+
return ctx.nb.queue.ack(note_id, outcome, error=error, revision_id=revision_id)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def note_queue_stats(ctx: ToolContext) -> Dict[str, Any]:
|
|
22
|
+
stats = ctx.nb.queue.stats()
|
|
23
|
+
return {k: stats[k] for k in ("queued", "processing", "failed", "by_priority", "oldest_enqueued_at")}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Recall and reflect tools (docs/api.md): note_search, note_expand, note_changes, note_entity,
|
|
2
|
+
note_timeline, note_open_loops, note_contradictions, note_resurface, note_anniversaries,
|
|
3
|
+
note_insight_write."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
from ..recall import expand as _expand
|
|
10
|
+
from ..recall import reflect as _reflect
|
|
11
|
+
from ..recall.insights import write_insight
|
|
12
|
+
from ..recall.search import search
|
|
13
|
+
from .context import ToolContext
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _blank(v: Any) -> Any:
|
|
17
|
+
return None if v in ("", {}, []) else v
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def note_search(ctx: ToolContext, query: str = "", since: Optional[str] = None, until: Optional[str] = None,
|
|
21
|
+
kinds: Optional[List[str]] = None, entities: Optional[List[str]] = None,
|
|
22
|
+
topics: Optional[List[str]] = None, limit: int = 20, as_of: Optional[str] = None) -> Dict[str, Any]:
|
|
23
|
+
return search(ctx.nb, query or "", since=_blank(since), until=_blank(until), kinds=_blank(kinds),
|
|
24
|
+
entities=_blank(entities), topics=_blank(topics), limit=int(limit or 20), as_of=_blank(as_of))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def note_expand(ctx: ToolContext, note_ids: Optional[List[str]] = None, entity_ids: Optional[List[str]] = None,
|
|
28
|
+
as_of: Optional[str] = None) -> Dict[str, Any]:
|
|
29
|
+
return _expand.expand(ctx.nb, note_ids or [], entity_ids or [], _blank(as_of))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def note_changes(ctx: ToolContext, since: Optional[str] = None, limit: int = 50) -> Dict[str, Any]:
|
|
33
|
+
return ctx.nb.changes(_blank(since), int(limit or 50))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def note_entity(ctx: ToolContext, entity_id: str, at: Optional[str] = None) -> Dict[str, Any]:
|
|
37
|
+
return _expand.entity_card(ctx.nb, entity_id, _blank(at))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def note_timeline(ctx: ToolContext, since: Optional[str] = None, until: Optional[str] = None,
|
|
41
|
+
granularity: str = "day") -> Dict[str, Any]:
|
|
42
|
+
return ctx.nb.timeline(_blank(since), _blank(until), granularity or "day")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def note_open_loops(ctx: ToolContext, limit: int = 20, since: Optional[str] = None) -> Dict[str, Any]:
|
|
46
|
+
return _reflect.open_loops(ctx.nb, int(limit or 20), _blank(since))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def note_contradictions(ctx: ToolContext, limit: int = 20, since: Optional[str] = None) -> Dict[str, Any]:
|
|
50
|
+
return _reflect.contradictions(ctx.nb, int(limit or 20), _blank(since))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def note_resurface(ctx: ToolContext, limit: int = 20, since: Optional[str] = None) -> Dict[str, Any]:
|
|
54
|
+
return _reflect.resurface(ctx.nb, int(limit or 20), _blank(since))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def note_anniversaries(ctx: ToolContext, limit: int = 20, since: Optional[str] = None) -> Dict[str, Any]:
|
|
58
|
+
return _reflect.anniversaries(ctx.nb, int(limit or 20), _blank(since))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def note_insight_write(ctx: ToolContext, kind: str, title: str, body: str,
|
|
62
|
+
evidence_note_ids: Optional[List[str]] = None, entity_ids: Optional[List[str]] = None,
|
|
63
|
+
run_id: Optional[str] = None) -> Dict[str, Any]:
|
|
64
|
+
return write_insight(ctx.nb, kind, title, body, evidence_note_ids or [], entity_ids or [], _blank(run_id))
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: abstractnote
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AbstractNote: one endpoint for your notes; a temporal semantic knowledge graph of your life, built on AbstractGateway, AbstractRuntime and AbstractMemory
|
|
5
|
+
Author-email: Laurent-Philippe Albou <contact@abstractcore.ai>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/lpalbou/abstractnote
|
|
8
|
+
Project-URL: Repository, https://github.com/lpalbou/abstractnote
|
|
9
|
+
Project-URL: Issues, https://github.com/lpalbou/abstractnote/issues
|
|
10
|
+
Keywords: notes,knowledge-graph,memory,temporal,abstractframework
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: abstractmemory>=0.3.1
|
|
22
|
+
Requires-Dist: abstractsemantics>=0.0.5
|
|
23
|
+
Requires-Dist: abstractruntime>=0.10.2
|
|
24
|
+
Requires-Dist: abstractcore>=2.26.3
|
|
25
|
+
Requires-Dist: pydantic>=2.0
|
|
26
|
+
Provides-Extra: gateway
|
|
27
|
+
Requires-Dist: abstractgateway>=0.15.11; extra == "gateway"
|
|
28
|
+
Requires-Dist: fastapi>=0.110; extra == "gateway"
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
31
|
+
Requires-Dist: ruff>=0.1; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# AbstractNote
|
|
35
|
+
|
|
36
|
+
One endpoint for everything you want to remember. Send a sentence, a document, a photo, a
|
|
37
|
+
recording, a log. AbstractNote files it with its time, place and provenance, links it to what
|
|
38
|
+
you already know, and grows a **temporal semantic knowledge graph** of your ideas, projects,
|
|
39
|
+
people and decisions. You search it by effort (quick, standard, deep) and revisit it through
|
|
40
|
+
time: what you knew, when you learned it, and how your view changed.
|
|
41
|
+
|
|
42
|
+
AbstractNote is part of the AbstractFramework. It runs on **AbstractGateway** (sign-in, one
|
|
43
|
+
runtime and one notebook per user, app hosting), **AbstractRuntime** and **AbstractFlow** (the
|
|
44
|
+
capture and understanding workflows), **AbstractMemory** (temporal facts) and **AbstractCore**
|
|
45
|
+
(models, structured output, embeddings).
|
|
46
|
+
|
|
47
|
+
Status: version 0.1.0, in development. It is not published on PyPI or npm yet; you install it
|
|
48
|
+
from source (see [Install](#install)).
|
|
49
|
+
|
|
50
|
+
## What you get
|
|
51
|
+
|
|
52
|
+
- **Capture anywhere**: the web app, the Apple Watch app, the `abstractnote` command line, and
|
|
53
|
+
agents. Attachments are de-duplicated by content; a retried send never saves a note twice.
|
|
54
|
+
- **Automatic filing**: a small fast model gives each note a title, a kind, topics, an urgency
|
|
55
|
+
and its language, next to the time and place it was made.
|
|
56
|
+
- **Self-organising knowledge**: a model resolves people, projects and ideas against candidates
|
|
57
|
+
that exact lookups, full-text and vectors produce. Every change is a revision you can replay,
|
|
58
|
+
and every claim keeps the note it came from.
|
|
59
|
+
- **Remember by cue and effort**: quick (no model, under a second), standard (one synthesis with
|
|
60
|
+
citations), deep (a background dossier). Then-versus-now for any person, project, idea or link.
|
|
61
|
+
- **Reflection**: open loops, contradictions, things that went quiet and connections you may have
|
|
62
|
+
missed, surfaced as insights.
|
|
63
|
+
- **Imports**: Claude Code sessions, an agora hub database, Markdown trees and plain files, with
|
|
64
|
+
their original dates and provenance.
|
|
65
|
+
|
|
66
|
+
## Install
|
|
67
|
+
|
|
68
|
+
AbstractNote needs Python 3.10 or later and the framework packages next to it. Until the
|
|
69
|
+
packages are published, install everything from source checkouts:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
python3 -m venv .venv
|
|
73
|
+
.venv/bin/pip install -e ../abstractsemantics -e ../abstractcore -e ../abstractruntime \
|
|
74
|
+
-e ../abstractmemory # a checkout that has close_assertion, see below
|
|
75
|
+
.venv/bin/pip install -e .
|
|
76
|
+
.venv/bin/abstractnote --help
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Two framework changes that AbstractNote relies on are not in released packages yet:
|
|
80
|
+
|
|
81
|
+
- **AbstractMemory `close_assertion`** (the `close-assertion` branch): closes a superseded fact in
|
|
82
|
+
place. AbstractNote's first release waits for the AbstractMemory release that contains it.
|
|
83
|
+
- **The AbstractGateway integration** (the `abstractnote-seam` branch): binds the note tools to
|
|
84
|
+
each user's notebook, serves the note read API and lists the Note app. You need it only for the
|
|
85
|
+
gateway path; the headless notebook works without a gateway. The gateway must carry this seam
|
|
86
|
+
(`abstractgateway.abstractnote_seam`; the first gateway release with it is named at
|
|
87
|
+
AbstractNote's release): the `[gateway]` extra alone does not guarantee it, and
|
|
88
|
+
`abstractnote.gateway` refuses to import into a gateway without it, with one sentence.
|
|
89
|
+
|
|
90
|
+
To run models locally on Apple silicon, add `pip install "abstractcore[apple]"` (MLX).
|
|
91
|
+
|
|
92
|
+
## Quick start
|
|
93
|
+
|
|
94
|
+
A headless notebook on this machine, no gateway and no model:
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
abstractnote capture "Met Dana about the LUT budget; she wants the Pareto by Friday." --notebook ~/notes
|
|
98
|
+
abstractnote search "LUT budget" --notebook ~/notes
|
|
99
|
+
abstractnote status --notebook ~/notes
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Let a model understand the queued notes (entities, claims, tasks), then ask a question:
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
abstractnote process --local --notebook ~/notes --provider mlx --model mlx-community/Qwen3.6-35B-A3B-4bit
|
|
106
|
+
abstractnote search "What does Dana want?" --effort standard --notebook ~/notes \
|
|
107
|
+
--provider mlx --model mlx-community/Qwen3.6-35B-A3B-4bit
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
A notebook is pinned to the embedding model that created it. Choose it on the first command
|
|
111
|
+
(`--embed-base-url` and `--embed-model`, for example a local LM Studio embedding model) and give
|
|
112
|
+
the same flags every time, or start with the built-in offline embedder and change later with
|
|
113
|
+
`abstractnote rebuild --reembed`. See [docs/getting-started.md](docs/getting-started.md).
|
|
114
|
+
|
|
115
|
+
Through a gateway, clients start the `note-capture` and `note-recall` flows with the gateway's
|
|
116
|
+
runs API, and `POST /api/gateway/note/processor/ensure` creates the per-user processor that
|
|
117
|
+
understands notes in the background. The getting-started guide walks through a private scratch
|
|
118
|
+
gateway, the web app and the watch app.
|
|
119
|
+
|
|
120
|
+
## Clients
|
|
121
|
+
|
|
122
|
+
| Client | Where | What it does |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| Web app | [`web/`](web/) (`@abstractframework/note`) | Capture (a chat with attachments), Graph (entities and links through time), Remember (since you last looked, cue cards, ask by effort, then and now), Settings |
|
|
125
|
+
| Apple Watch app | [`watch/apple/`](watch/apple/README.md) | Note, Ask, Today, Settings; watch-only, watchOS 26 or later |
|
|
126
|
+
| Command line | `abstractnote` | Headless capture, search, processing, imports, rebuild, JSON output for scripts and agents |
|
|
127
|
+
|
|
128
|
+
An iPhone app is planned ([`phone/iphone/`](phone/iphone/README.md)). Agents use the same note
|
|
129
|
+
tools and flows, with the effort level as a parameter.
|
|
130
|
+
|
|
131
|
+
## Documentation
|
|
132
|
+
|
|
133
|
+
- [docs/README.md](docs/README.md): the documentation index
|
|
134
|
+
- [Getting started](docs/getting-started.md): a headless notebook in five minutes, then the gateway path
|
|
135
|
+
- [Architecture](docs/architecture.md): components, data plane, request lifecycles
|
|
136
|
+
- [API](docs/api.md): runs API usage, note read API, note tools, Python API, CLI
|
|
137
|
+
- [Data model](docs/data-model.md), [Cognition](docs/cognition.md), [Remembering](docs/remembering.md),
|
|
138
|
+
[Clients](docs/clients.md), [Importers](docs/importers.md)
|
|
139
|
+
- [FAQ](docs/faq.md) and [Troubleshooting](docs/troubleshooting.md)
|
|
140
|
+
- [Contributing](CONTRIBUTING.md), [Security](SECURITY.md), [Changelog](CHANGELOG.md),
|
|
141
|
+
[Acknowledgements](ACKNOWLEDGEMENTS.md)
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
MIT. See [LICENSE](LICENSE).
|