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.
Files changed (80) hide show
  1. abstractnote/__init__.py +18 -0
  2. abstractnote/cli.py +240 -0
  3. abstractnote/flows/__init__.py +2 -0
  4. abstractnote/flows/build.py +970 -0
  5. abstractnote/flows/bundle.py +62 -0
  6. abstractnote/flows/contract.py +88 -0
  7. abstractnote/flows/graph.py +540 -0
  8. abstractnote/flows/json/note-capture.json +2280 -0
  9. abstractnote/flows/json/note-drain.json +2012 -0
  10. abstractnote/flows/json/note-mine.json +2392 -0
  11. abstractnote/flows/json/note-process.json +1853 -0
  12. abstractnote/flows/json/note-recall.json +1876 -0
  13. abstractnote/flows/json/note-reflect.json +2057 -0
  14. abstractnote/flows/prompts_fallback.py +119 -0
  15. abstractnote/flows/runner.py +320 -0
  16. abstractnote/flows/schemas_fallback.py +175 -0
  17. abstractnote/gateway/__init__.py +24 -0
  18. abstractnote/gateway/appspec.py +10 -0
  19. abstractnote/gateway/router.py +571 -0
  20. abstractnote/gateway/tools.py +74 -0
  21. abstractnote/ids.py +177 -0
  22. abstractnote/importers/__init__.py +142 -0
  23. abstractnote/importers/__main__.py +3 -0
  24. abstractnote/importers/agora.py +203 -0
  25. abstractnote/importers/claude_sessions.py +415 -0
  26. abstractnote/importers/cli.py +113 -0
  27. abstractnote/importers/files.py +72 -0
  28. abstractnote/importers/markdown.py +165 -0
  29. abstractnote/ingest/__init__.py +7 -0
  30. abstractnote/ingest/blobs.py +47 -0
  31. abstractnote/ingest/capture.py +265 -0
  32. abstractnote/ingest/extract.py +118 -0
  33. abstractnote/ingest/normalise.py +95 -0
  34. abstractnote/model.py +229 -0
  35. abstractnote/process/__init__.py +5 -0
  36. abstractnote/process/context.py +218 -0
  37. abstractnote/process/merge.py +60 -0
  38. abstractnote/process/prompts/__init__.py +39 -0
  39. abstractnote/process/prompts/extract.md +30 -0
  40. abstractnote/process/prompts/link.md +32 -0
  41. abstractnote/process/prompts/mine_dossier.md +13 -0
  42. abstractnote/process/prompts/mine_findings.md +13 -0
  43. abstractnote/process/prompts/mine_plan.md +13 -0
  44. abstractnote/process/prompts/quick_filing.md +23 -0
  45. abstractnote/process/prompts/reflect.md +10 -0
  46. abstractnote/process/prompts/synthesize.md +17 -0
  47. abstractnote/process/resolve.py +96 -0
  48. abstractnote/process/revise.py +410 -0
  49. abstractnote/process/schemas.py +377 -0
  50. abstractnote/process/vocabulary.py +68 -0
  51. abstractnote/recall/__init__.py +1 -0
  52. abstractnote/recall/changes.py +191 -0
  53. abstractnote/recall/expand.py +379 -0
  54. abstractnote/recall/insights.py +62 -0
  55. abstractnote/recall/reflect.py +202 -0
  56. abstractnote/recall/review.py +191 -0
  57. abstractnote/recall/search.py +205 -0
  58. abstractnote/recall/shapes.py +52 -0
  59. abstractnote/recall/then_now.py +100 -0
  60. abstractnote/store/__init__.py +12 -0
  61. abstractnote/store/archive.py +166 -0
  62. abstractnote/store/embeddings.py +275 -0
  63. abstractnote/store/kg.py +219 -0
  64. abstractnote/store/notebook.py +829 -0
  65. abstractnote/store/notes.py +1190 -0
  66. abstractnote/store/queue.py +159 -0
  67. abstractnote/store/schema.py +348 -0
  68. abstractnote/store/vector_index.py +88 -0
  69. abstractnote/tools/__init__.py +215 -0
  70. abstractnote/tools/capture.py +121 -0
  71. abstractnote/tools/context.py +19 -0
  72. abstractnote/tools/process.py +22 -0
  73. abstractnote/tools/queue.py +23 -0
  74. abstractnote/tools/recall.py +64 -0
  75. abstractnote-0.1.0.dist-info/METADATA +145 -0
  76. abstractnote-0.1.0.dist-info/RECORD +80 -0
  77. abstractnote-0.1.0.dist-info/WHEEL +5 -0
  78. abstractnote-0.1.0.dist-info/entry_points.txt +2 -0
  79. abstractnote-0.1.0.dist-info/licenses/LICENSE +21 -0
  80. abstractnote-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,571 @@
1
+ """The note read API (docs/api.md "Note read API"), mounted by the gateway at ``/api/gateway/note``.
2
+
3
+ Every request resolves the caller's own plane through the gateway (``get_gateway_service()`` ->
4
+ ``svc.config.data_dir / "abstractnote"``): clients never name a user, tenant or runtime, and an
5
+ id from another notebook simply is not there (404). Every read delegates to the core lane's
6
+ ``Notebook`` so "as of" and history have one implementation (review 01 S4). The write actions
7
+ here are bookkeeping (user-authored revisions, acknowledgements, queue retries, settings,
8
+ automation set-up), never knowledge; knowledge changes go through the flows.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import threading
15
+ from pathlib import Path
16
+ from typing import Any, Callable, Dict, List, Optional
17
+
18
+ from fastapi import APIRouter, HTTPException, Query, Request
19
+ from fastapi.responses import Response
20
+ from pydantic import BaseModel, ConfigDict
21
+
22
+ from ..flows.build import BUNDLE_ID, DRAIN_DEFAULTS, NOTE_TOOLS
23
+ from .tools import notebook_dir
24
+
25
+ router = APIRouter(prefix="/note", tags=["note"])
26
+
27
+ PROCESSOR_REQUEST_ID = "abstractnote-processor"
28
+ REFLECT_REQUEST_ID = "abstractnote-reflect"
29
+
30
+ _notebooks: Dict[str, Any] = {}
31
+ _notebooks_lock = threading.Lock()
32
+
33
+
34
+ # --------------------------------------------------------------------------- plumbing
35
+
36
+
37
+ def _principal(request: Request) -> Any:
38
+ from abstractgateway.routes.gateway import _principal_from_request
39
+
40
+ return _principal_from_request(request)
41
+
42
+
43
+ def _service() -> Any:
44
+ from abstractgateway.service import get_gateway_service
45
+
46
+ return get_gateway_service()
47
+
48
+
49
+ def plane_embedder(data_dir: Path) -> Any:
50
+ """The plane's embedding route (``None`` when none is configured: the notebook keeps its pin)."""
51
+ from abstractgateway.memory_store import build_gateway_memory_embedder
52
+
53
+ return build_gateway_memory_embedder(base_dir=Path(data_dir))
54
+
55
+
56
+ def notebook_for(data_dir: Path) -> Any:
57
+ """The plane's Notebook, opened once per process (W1's ``Notebook.open``)."""
58
+ from abstractnote.store.notebook import Notebook # core lane
59
+
60
+ path = str(notebook_dir(Path(data_dir)).resolve())
61
+ with _notebooks_lock:
62
+ nb = _notebooks.get(path)
63
+ if nb is None:
64
+ nb = Notebook.open(path, embedder=plane_embedder(Path(data_dir)))
65
+ _notebooks[path] = nb
66
+ return nb
67
+
68
+
69
+ def close_notebooks() -> None:
70
+ """Close every cached notebook (tests, shutdown)."""
71
+ with _notebooks_lock:
72
+ for nb in _notebooks.values():
73
+ try:
74
+ nb.close()
75
+ except Exception: # noqa: BLE001 - closing is best effort
76
+ pass
77
+ _notebooks.clear()
78
+
79
+
80
+ def _missing(what: str) -> HTTPException:
81
+ return HTTPException(status_code=404, detail=f"That {what} is not in your notebook.")
82
+
83
+
84
+ def _exists(nb: Any, kind: str, ident: str) -> bool:
85
+ """Is this id in the caller's notebook? (an existence check, so an absent id is 404 and only
86
+ genuinely bad input is 400; the core lane signals both with ValueError)."""
87
+ store = nb.store
88
+ if kind == "note":
89
+ return store.note_row(ident) is not None
90
+ if kind == "entity":
91
+ return store.entity_row(ident) is not None
92
+ if kind == "claim":
93
+ return store.claim_row(ident) is not None
94
+ if kind == "task":
95
+ return store.q1("SELECT 1 FROM tasks WHERE task_id=?", (ident,)) is not None
96
+ raise ValueError(kind)
97
+
98
+
99
+ def _require(nb: Any, kind: str, ident: str, what: str) -> None:
100
+ if not _exists(nb, kind, ident):
101
+ raise _missing(what)
102
+
103
+
104
+ def _call(nb: Any, name: str, *args: Any, **kwargs: Any) -> Any:
105
+ fn = getattr(nb, name, None)
106
+ if not callable(fn): # a core-lane method this version does not have yet: loud, never a fake answer
107
+ raise HTTPException(status_code=501, detail=f"This notebook cannot answer {name} yet.")
108
+ try:
109
+ return fn(*args, **kwargs)
110
+ except ValueError as e:
111
+ raise HTTPException(status_code=400, detail=str(e)) from e
112
+
113
+
114
+ def _clean(**kwargs: Any) -> Dict[str, Any]:
115
+ return {k: v for k, v in kwargs.items() if v is not None}
116
+
117
+
118
+ def _csv(value: Optional[str]) -> Optional[List[str]]:
119
+ if value is None:
120
+ return None
121
+ items = [v.strip() for v in str(value).split(",") if v.strip()]
122
+ return items or None
123
+
124
+
125
+ async def _run(request: Request, work: Callable[[Any, Any, Any], Any]) -> Any:
126
+ """Resolve principal + plane on the request, run ``work(nb, svc, principal)`` off the loop."""
127
+ principal = _principal(request)
128
+ svc = _service()
129
+ data_dir = Path(svc.config.data_dir)
130
+
131
+ def _job() -> Any:
132
+ return work(notebook_for(data_dir), svc, principal)
133
+
134
+ return await asyncio.to_thread(_job)
135
+
136
+
137
+ def _account(principal: Any) -> str:
138
+ return str(getattr(principal, "user_id", "") or "")
139
+
140
+
141
+ # The app-server session proxy marks every request it relays with the app's id (it drops any value
142
+ # the browser sent): `X-AbstractFramework-App-Proxy: abstractnote` is the Note web app.
143
+ APP_PROXY_HEADER = "X-AbstractFramework-App-Proxy"
144
+ APP_CLIENTS = {"abstractnote": "web"}
145
+
146
+
147
+ def action_client(request: Request) -> str:
148
+ """``source.client`` of a user action: ``web`` through the Note web app's proxy, else ``api``.
149
+ Informational only; the signed-in account (the principal) is what is authoritative."""
150
+ app = str(request.headers.get(APP_PROXY_HEADER) or "").strip().lower()
151
+ return APP_CLIENTS.get(app, "api")
152
+
153
+
154
+ def _items(value: Any) -> Dict[str, Any]:
155
+ """Every list answers ``{items, next_cursor}`` (api.md); ``next_cursor`` is null when the list
156
+ does not page."""
157
+ if isinstance(value, dict) and "items" in value:
158
+ return {**value, "next_cursor": value.get("next_cursor")}
159
+ return {"items": list(value or []), "next_cursor": None}
160
+
161
+
162
+ # --------------------------------------------------------------------------- status and changes
163
+
164
+
165
+ def _automation_ids(nb: Any) -> Dict[str, Optional[str]]:
166
+ return {"processor_id": nb.meta("processor_automation_id"), "reflect_id": nb.meta("reflect_automation_id")}
167
+
168
+
169
+ @router.get("/status")
170
+ async def note_status(request: Request) -> Dict[str, Any]:
171
+ def work(nb, svc, principal):
172
+ out = dict(_call(nb, "status"))
173
+ out["automations"] = _automation_ids(nb)
174
+ out.pop("notebook", None) # a server path: not for clients
175
+ return out
176
+
177
+ return await _run(request, work)
178
+
179
+
180
+ @router.get("/changes")
181
+ async def note_changes(request: Request, since: Optional[str] = None, limit: int = Query(100, ge=1, le=1000),
182
+ as_received: Optional[str] = None) -> Dict[str, Any]:
183
+ return await _run(request, lambda nb, svc, p: _call(nb, "changes", since=since, limit=limit,
184
+ **_clean(as_received=as_received or None)))
185
+
186
+
187
+ @router.get("/overview")
188
+ async def note_overview(request: Request, days: int = Query(30, ge=1, le=3660)) -> Dict[str, Any]:
189
+ return await _run(request, lambda nb, svc, p: _call(nb, "overview", days=days))
190
+
191
+
192
+ # --------------------------------------------------------------------------- notes
193
+
194
+
195
+ @router.get("/notes")
196
+ async def note_list(
197
+ request: Request, q: Optional[str] = None, since: Optional[str] = None, until: Optional[str] = None,
198
+ kind: Optional[str] = None, topic: Optional[str] = None, entity: Optional[str] = None,
199
+ status: Optional[str] = None, cursor: Optional[str] = None, limit: int = Query(50, ge=1, le=500),
200
+ ) -> Dict[str, Any]:
201
+ return await _run(request, lambda nb, svc, p: _items(_call(nb, "notes", **_clean(
202
+ q=q, since=since, until=until, kind=kind, topic=topic, entity=entity, status=status, cursor=cursor, limit=limit))))
203
+
204
+
205
+ @router.get("/notes/{note_id}")
206
+ async def note_get(request: Request, note_id: str) -> Dict[str, Any]:
207
+ def work(nb, svc, p):
208
+ note = _call(nb, "note", note_id)
209
+ if note is None:
210
+ raise _missing("note")
211
+ return note
212
+
213
+ return await _run(request, work)
214
+
215
+
216
+ @router.get("/notes/{note_id}/attachments/{attachment_id}")
217
+ async def note_attachment(request: Request, note_id: str, attachment_id: str) -> Response:
218
+ def work(nb, svc, p):
219
+ _require(nb, "note", note_id, "note")
220
+ try:
221
+ blob = nb.attachment_bytes(note_id, attachment_id)
222
+ except ValueError:
223
+ raise _missing("attachment")
224
+ return blob
225
+
226
+ blob = await _run(request, work)
227
+ filename = str(blob.get("filename") or attachment_id).replace('"', "")
228
+ return Response(
229
+ content=blob["data"], media_type=str(blob.get("mime") or "application/octet-stream"),
230
+ headers={"Content-Disposition": f'inline; filename="{filename}"', "Cache-Control": "private, max-age=3600"},
231
+ )
232
+
233
+
234
+ @router.post("/notes/{note_id}/archive")
235
+ async def note_archive(request: Request, note_id: str) -> Dict[str, Any]:
236
+ def work(nb, svc, p):
237
+ _require(nb, "note", note_id, "note")
238
+ return _call(nb, "archive_note", note_id)
239
+
240
+ return await _run(request, work)
241
+
242
+
243
+ # --------------------------------------------------------------------------- search and entities
244
+
245
+
246
+ @router.get("/search")
247
+ async def note_search(
248
+ request: Request, q: str = Query(..., min_length=1), since: Optional[str] = None, until: Optional[str] = None,
249
+ kinds: Optional[str] = None, entities: Optional[str] = None, topics: Optional[str] = None,
250
+ limit: int = Query(20, ge=1, le=200),
251
+ ) -> Dict[str, Any]:
252
+ return await _run(request, lambda nb, svc, p: _call(nb, "search", q, **_clean(
253
+ since=since, until=until, kinds=_csv(kinds), entities=_csv(entities), topics=_csv(topics), limit=limit)))
254
+
255
+
256
+ @router.get("/entities")
257
+ async def note_entities(
258
+ request: Request, q: Optional[str] = None, kind: Optional[str] = None, sort: Optional[str] = None,
259
+ cursor: Optional[str] = None, limit: int = Query(50, ge=1, le=500), include_merged: bool = False,
260
+ ) -> Dict[str, Any]:
261
+ return await _run(request, lambda nb, svc, p: _items(_call(nb, "entities", **_clean(
262
+ q=q, kind=kind, sort=sort, cursor=cursor, limit=limit, include_merged=include_merged or None))))
263
+
264
+
265
+ @router.get("/entities/{entity_id}")
266
+ async def note_entity(request: Request, entity_id: str, at: Optional[str] = None,
267
+ as_received: Optional[str] = None, as_merged: bool = False) -> Dict[str, Any]:
268
+ def work(nb, svc, p):
269
+ _require(nb, "entity", entity_id, "entity")
270
+ if as_merged:
271
+ if at or as_received:
272
+ raise HTTPException(status_code=400, detail="An entry's own record has no date; leave out at.")
273
+ return _call(nb, "entity_record", entity_id)
274
+ return _call(nb, "entity", entity_id, **_clean(at=at or None, as_received=as_received or None))
275
+
276
+ return await _run(request, work)
277
+
278
+
279
+ def _entity_or_404(nb: Any, entity_id: str) -> None:
280
+ _require(nb, "entity", entity_id, "entity")
281
+
282
+
283
+ @router.get("/entities/{entity_id}/timeline")
284
+ async def note_entity_timeline(request: Request, entity_id: str, since: Optional[str] = None,
285
+ until: Optional[str] = None) -> Dict[str, Any]:
286
+ def work(nb, svc, p):
287
+ _entity_or_404(nb, entity_id)
288
+ return _call(nb, "entity_timeline", entity_id, **_clean(since=since, until=until))
289
+
290
+ return await _run(request, work)
291
+
292
+
293
+ @router.get("/entities/{entity_id}/then-now")
294
+ async def note_entity_then_now(request: Request, entity_id: str) -> Dict[str, Any]:
295
+ def work(nb, svc, p):
296
+ _entity_or_404(nb, entity_id)
297
+ return _call(nb, "then_now", entity_id)
298
+
299
+ return await _run(request, work)
300
+
301
+
302
+ @router.get("/relations/{a}/{b}")
303
+ async def note_relation(request: Request, a: str, b: str, since: Optional[str] = None,
304
+ until: Optional[str] = None) -> Dict[str, Any]:
305
+ def work(nb, svc, p):
306
+ _entity_or_404(nb, a)
307
+ _entity_or_404(nb, b)
308
+ return _call(nb, "relation", a, b, **_clean(since=since, until=until))
309
+
310
+ return await _run(request, work)
311
+
312
+
313
+ class UnmergeBody(BaseModel):
314
+ model_config = ConfigDict(extra="forbid")
315
+
316
+ from_id: str
317
+ reason: str = ""
318
+
319
+
320
+ @router.post("/entities/{entity_id}/unmerge")
321
+ async def note_unmerge(request: Request, entity_id: str, body: UnmergeBody) -> Dict[str, Any]:
322
+ def work(nb, svc, p):
323
+ _entity_or_404(nb, entity_id)
324
+ _entity_or_404(nb, body.from_id)
325
+ return _call(nb, "unmerge_entity", entity_id, body.from_id, _account(p), body.reason,
326
+ client=action_client(request))
327
+
328
+ return await _run(request, work)
329
+
330
+
331
+ # --------------------------------------------------------------------------- review
332
+
333
+
334
+ @router.get("/review")
335
+ async def note_review(request: Request, limit: int = Query(50, ge=1, le=500)) -> Dict[str, Any]:
336
+ return await _run(request, lambda nb, svc, p: _items(_call(nb, "review", limit=limit)))
337
+
338
+
339
+ class ConfirmBody(BaseModel):
340
+ model_config = ConfigDict(extra="forbid")
341
+
342
+
343
+ @router.post("/review/{review_id}/confirm")
344
+ async def note_review_confirm(request: Request, review_id: str, body: Optional[ConfirmBody] = None) -> Dict[str, Any]:
345
+ def work(nb, svc, p):
346
+ if _call(nb, "review_item", review_id) is None:
347
+ raise HTTPException(status_code=404, detail="That match is not waiting for review.")
348
+ return _call(nb, "confirm_match", review_id, _account(p), client=action_client(request))
349
+
350
+ return await _run(request, work)
351
+
352
+
353
+ # --------------------------------------------------------------------------- claims, tasks, cues
354
+
355
+
356
+ @router.get("/claims/{claim_id}")
357
+ async def note_claim(request: Request, claim_id: str) -> Dict[str, Any]:
358
+ def work(nb, svc, p):
359
+ _require(nb, "claim", claim_id, "statement")
360
+ return _call(nb, "claim", claim_id)
361
+
362
+ return await _run(request, work)
363
+
364
+
365
+ class RetractBody(BaseModel):
366
+ model_config = ConfigDict(extra="forbid")
367
+
368
+ reason: str
369
+
370
+
371
+ @router.post("/claims/{claim_id}/retract")
372
+ async def note_claim_retract(request: Request, claim_id: str, body: RetractBody) -> Dict[str, Any]:
373
+ def work(nb, svc, p):
374
+ _require(nb, "claim", claim_id, "statement")
375
+ return _call(nb, "retract_claim", claim_id, _account(p), body.reason, client=action_client(request))
376
+
377
+ return await _run(request, work)
378
+
379
+
380
+ class CloseTaskBody(BaseModel):
381
+ model_config = ConfigDict(extra="forbid")
382
+
383
+ note: Optional[str] = None
384
+
385
+
386
+ @router.post("/tasks/{task_id}/close")
387
+ async def note_task_close(request: Request, task_id: str, body: Optional[CloseTaskBody] = None) -> Dict[str, Any]:
388
+ body = body or CloseTaskBody()
389
+
390
+ def work(nb, svc, p):
391
+ _require(nb, "task", task_id, "task")
392
+ return _call(nb, "close_task", task_id, _account(p), body.note, client=action_client(request))
393
+
394
+ return await _run(request, work)
395
+
396
+
397
+ @router.get("/tasks")
398
+ async def note_tasks(request: Request, status: Optional[str] = "open", entity: Optional[str] = None,
399
+ limit: int = Query(100, ge=1, le=1000)) -> Dict[str, Any]:
400
+ return await _run(request, lambda nb, svc, p: _items(_call(nb, "tasks", status=status, entity=entity, limit=limit)))
401
+
402
+
403
+ @router.get("/cues")
404
+ async def note_cues(request: Request, limit: int = Query(3, ge=1, le=50)) -> Dict[str, Any]:
405
+ return await _run(request, lambda nb, svc, p: _items(_call(nb, "cues", limit=limit)))
406
+
407
+
408
+ # --------------------------------------------------------------------------- graph and time
409
+
410
+
411
+ @router.get("/graph")
412
+ async def note_graph(
413
+ request: Request, focus: Optional[str] = None, at: Optional[str] = None, depth: int = Query(1, ge=1, le=3),
414
+ since: Optional[str] = None, until: Optional[str] = None, kinds: Optional[str] = None,
415
+ min_count: int = Query(1, ge=1), limit: int = Query(200, ge=1, le=2000), as_received: Optional[str] = None,
416
+ ) -> Dict[str, Any]:
417
+ return await _run(request, lambda nb, svc, p: _call(nb, "graph", **_clean(
418
+ focus=_csv(focus), at=at, depth=depth, since=since, until=until, kinds=_csv(kinds), min_count=min_count,
419
+ limit=limit, as_received=as_received or None)))
420
+
421
+
422
+ @router.get("/timeline")
423
+ async def note_timeline(request: Request, since: Optional[str] = None, until: Optional[str] = None,
424
+ granularity: str = Query("day", pattern="^(day|week|month)$")) -> Dict[str, Any]:
425
+ return await _run(request, lambda nb, svc, p: _call(nb, "timeline", since=since, until=until, granularity=granularity))
426
+
427
+
428
+ @router.get("/day/{day}")
429
+ async def note_day(request: Request, day: str, tz: Optional[str] = None,
430
+ as_received: Optional[str] = None) -> Dict[str, Any]:
431
+ return await _run(request, lambda nb, svc, p: _call(nb, "day", day, tz, as_received or None))
432
+
433
+
434
+ @router.get("/revisions")
435
+ async def note_revisions(request: Request, since: Optional[str] = None, until: Optional[str] = None,
436
+ entity: Optional[str] = None, cursor: Optional[str] = None,
437
+ limit: int = Query(50, ge=1, le=500)) -> Dict[str, Any]:
438
+ return await _run(request, lambda nb, svc, p: _items(_call(nb, "revisions", **_clean(
439
+ since=since, until=until, entity=entity, cursor=cursor, limit=limit))))
440
+
441
+
442
+ @router.get("/topics")
443
+ async def note_topics(request: Request) -> Dict[str, Any]:
444
+ return await _run(request, lambda nb, svc, p: _items(_call(nb, "topics")))
445
+
446
+
447
+ # --------------------------------------------------------------------------- insights and queue
448
+
449
+
450
+ @router.get("/insights")
451
+ async def note_insights(request: Request, kind: Optional[str] = None, acked: Optional[bool] = None,
452
+ limit: int = Query(50, ge=1, le=500)) -> Dict[str, Any]:
453
+ return await _run(request, lambda nb, svc, p: _items(_call(nb, "insights", kind=kind, acked=acked, limit=limit)))
454
+
455
+
456
+ @router.post("/insights/{note_id}/ack")
457
+ async def note_insight_ack(request: Request, note_id: str) -> Dict[str, Any]:
458
+ def work(nb, svc, p):
459
+ _require(nb, "note", note_id, "insight")
460
+ return _call(nb, "ack_insight", note_id)
461
+
462
+ return await _run(request, work)
463
+
464
+
465
+ @router.get("/queue")
466
+ async def note_queue(request: Request, limit: int = Query(50, ge=1, le=500)) -> Dict[str, Any]:
467
+ def work(nb, svc, p):
468
+ out = dict(_call(nb.queue, "summary", limit=limit))
469
+ out["automations"] = _automation_ids(nb)
470
+ out["processor"] = _call(nb, "processor_throughput")
471
+ return out
472
+
473
+ return await _run(request, work)
474
+
475
+
476
+ @router.post("/queue/{note_id}/retry")
477
+ async def note_queue_retry(request: Request, note_id: str) -> Dict[str, Any]:
478
+ def work(nb, svc, p):
479
+ _require(nb, "note", note_id, "note")
480
+ return _call(nb.queue, "retry", note_id)
481
+
482
+ return await _run(request, work)
483
+
484
+
485
+ # --------------------------------------------------------------------------- settings and automations
486
+
487
+
488
+ @router.get("/settings")
489
+ async def note_settings(request: Request) -> Dict[str, Any]:
490
+ return await _run(request, lambda nb, svc, p: _call(nb, "settings"))
491
+
492
+
493
+ @router.put("/settings")
494
+ async def note_settings_put(request: Request, changes: Dict[str, Any]) -> Dict[str, Any]:
495
+ return await _run(request, lambda nb, svc, p: _call(nb, "update_settings", changes))
496
+
497
+
498
+ def _model_inputs(model_setting: Any) -> Dict[str, Any]:
499
+ m = model_setting if isinstance(model_setting, dict) else {}
500
+ return {k: str(m[k]) for k in ("provider", "model") if isinstance(m.get(k), str) and m[k].strip()}
501
+
502
+
503
+ def _existing_automation(svc: Any, automation_id: Optional[str]) -> Optional[str]:
504
+ if not automation_id:
505
+ return None
506
+ from abstractgateway.automation_errors import AutomationError
507
+ from abstractgateway.routes.automations import load_automation_controller
508
+
509
+ try:
510
+ load_automation_controller(svc, automation_id)
511
+ except AutomationError as e:
512
+ if e.status_code == 404:
513
+ return None
514
+ raise
515
+ return automation_id
516
+
517
+
518
+ def _after_one_interval(every: str) -> Dict[str, str]:
519
+ """``{start_at}`` one interval from now: the schedule's first occurrence is its next scheduled
520
+ time, not the moment it is created (a fixed-interval schedule anchors its grid on ``start_at``)."""
521
+ from datetime import datetime, timezone
522
+
523
+ from abstractruntime.triggers.protocol import format_timestamp, parse_duration
524
+
525
+ return {"start_at": format_timestamp(datetime.now(timezone.utc) + parse_duration(every, field="every"))}
526
+
527
+
528
+ def ensure_automations(nb: Any, svc: Any, principal: Any) -> Dict[str, Any]:
529
+ """Create the processor and reflect automations of this notebook when missing (idempotent).
530
+
531
+ The processor's first drain runs at once (an empty queue completes without a model call); the
532
+ reflect automation's first occurrence is one interval after creation, so opening an empty
533
+ notebook never starts an answering-model call ("Reflect now" = ``automation.run_now``)."""
534
+ from abstractgateway.routes.automations import CreateAutomationBody, _create
535
+
536
+ settings = nb.settings()
537
+ reflect_every = str((settings.get("reflect_schedule") or {}).get("every") or "7d")
538
+ processor = dict(settings["processor_schedule"])
539
+ out: Dict[str, Any] = {}
540
+ created: List[str] = []
541
+ plans = (
542
+ ("processor_automation_id", PROCESSOR_REQUEST_ID, "Process my notes", "note-drain",
543
+ {**DRAIN_DEFAULTS, "max_items": int(processor["max_items"]), **_model_inputs(settings.get("filing_model"))},
544
+ {"kind": "every", "every": str(processor["every"])}),
545
+ ("reflect_automation_id", REFLECT_REQUEST_ID, "Reflect on my notes", "note-reflect",
546
+ {"limit": 10, **_model_inputs(settings.get("answering_model"))},
547
+ {"kind": "every", "every": reflect_every, **_after_one_interval(reflect_every)}),
548
+ )
549
+ for meta_key, request_id, title, flow_id, inputs, schedule in plans:
550
+ automation_id = _existing_automation(svc, nb.meta(meta_key))
551
+ if automation_id is None:
552
+ body = CreateAutomationBody(
553
+ request_id=request_id, title=title,
554
+ target={"bundle_ref": BUNDLE_ID, "flow_id": flow_id,
555
+ "input_data": {**inputs, "_runtime": {"allowed_tools": list(NOTE_TOOLS)}}},
556
+ trigger={"source_id": "schedule", "source_version": 2, "config": dict(schedule)},
557
+ context={"mode": "independent"},
558
+ )
559
+ automation_id = str(_create(svc, principal, body)["automation_id"])
560
+ nb.set_meta(meta_key, automation_id)
561
+ if flow_id == "note-drain": # what GET /note/queue reports as the processor's throughput
562
+ nb.set_meta("processor_schedule_applied", processor)
563
+ created.append(flow_id)
564
+ out[meta_key.replace("_automation_id", "_id")] = automation_id
565
+ out["created"] = created
566
+ return out
567
+
568
+
569
+ @router.post("/processor/ensure")
570
+ async def note_processor_ensure(request: Request) -> Dict[str, Any]:
571
+ return await _run(request, ensure_automations)
@@ -0,0 +1,74 @@
1
+ """The note tools bound to one gateway plane (review 01 B3, M1, M10).
2
+
3
+ ``bound_tool_map`` is what the gateway's bundle host merges into its per-plane tool map next to
4
+ ``read_skill``. Inside the gateway the tools never see a filesystem path: attachments are only
5
+ ``{"$artifact": id}`` references, resolved through the HOST's artifact store (the one the
6
+ runtime already uses for uploads), and ``source.account`` is stamped with the plane's account.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Any, Callable, Dict, Mapping, Optional, Tuple
13
+
14
+ from ..flows.build import NOTE_TOOLS
15
+
16
+ #: The tool names the gateway adds to its approval auto-approve set (they read and write the
17
+ #: caller's own notebook only; a capture must never park on an approval wait).
18
+ AUTO_APPROVE_TOOL_NAMES: Tuple[str, ...] = tuple(NOTE_TOOLS)
19
+
20
+ NOTEBOOK_DIRNAME = "abstractnote"
21
+
22
+
23
+ def notebook_dir(data_root: Path) -> Path:
24
+ """The notebook of a plane: ``<plane data dir>/abstractnote``."""
25
+ return Path(data_root) / NOTEBOOK_DIRNAME
26
+
27
+
28
+ class ArtifactReader:
29
+ """``reader(ref) -> (bytes, mime, filename)`` over the host's artifact store.
30
+
31
+ Accepts only ``{"$artifact": id, ...}`` (the upload API's reference). Anything else, notably
32
+ ``{"path": ...}``, is refused: inside the gateway a tool runs as the gateway's own user, so a
33
+ path would let any signed-in user read the machine.
34
+ """
35
+
36
+ def __init__(self, artifact_store: Any) -> None:
37
+ if artifact_store is None or not callable(getattr(artifact_store, "load", None)):
38
+ raise ValueError("The note tools need the gateway's artifact store.")
39
+ self._store = artifact_store
40
+
41
+ def __call__(self, ref: Any) -> Tuple[bytes, str, Optional[str]]:
42
+ if not isinstance(ref, Mapping):
43
+ raise ValueError("An attachment must be an uploaded file reference.")
44
+ if "path" in ref:
45
+ raise ValueError("Files must be uploaded first; paths are not accepted here.")
46
+ artifact_id = str(ref.get("$artifact") or ref.get("artifact_id") or "").strip()
47
+ if not artifact_id:
48
+ raise ValueError("An attachment must be an uploaded file reference.")
49
+ artifact = self._store.load(artifact_id)
50
+ if artifact is None:
51
+ raise ValueError("That attachment was not found; upload it again.")
52
+ meta = artifact.metadata
53
+ tags = dict(getattr(meta, "tags", None) or {})
54
+ filename = tags.get("filename") or ref.get("filename") or None
55
+ mime = str(getattr(meta, "content_type", "") or ref.get("content_type") or "application/octet-stream")
56
+ return bytes(artifact.content), mime, filename
57
+
58
+
59
+ def bound_tool_map(data_root: Path, embedder: Any, artifact_store: Any, account: Optional[str]) -> Dict[str, Callable[..., Any]]:
60
+ """The note tools bound to ``<data_root>/abstractnote`` for one plane.
61
+
62
+ ``data_root`` is the plane's data dir (the host's ``data_root``), ``embedder`` the plane's
63
+ embedding route (``None`` keeps the notebook's own pin), ``artifact_store`` the host's
64
+ artifact store and ``account`` the plane's user id. Local paths are never allowed here.
65
+ """
66
+ from abstractnote.tools import bind # core lane: an ImportError here is a real gap, surfaced
67
+
68
+ tools = dict(bind(
69
+ notebook_dir(data_root), embedder=embedder, artifact_reader=ArtifactReader(artifact_store), account=account,
70
+ ))
71
+ missing = sorted(set(AUTO_APPROVE_TOOL_NAMES) - set(tools))
72
+ if missing:
73
+ raise RuntimeError("abstractnote.tools.bind does not provide " + ", ".join(missing))
74
+ return {name: tools[name] for name in AUTO_APPROVE_TOOL_NAMES}