loop-memory 0.4.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.
- loop_memory/__init__.py +62 -0
- loop_memory/backends/__init__.py +13 -0
- loop_memory/backends/embedding.py +82 -0
- loop_memory/backends/sentence_embedder.py +30 -0
- loop_memory/backends/vector_store.py +139 -0
- loop_memory/cli/__init__.py +0 -0
- loop_memory/cli/_common.py +68 -0
- loop_memory/cli/commands/__init__.py +13 -0
- loop_memory/cli/commands/cognitive.py +205 -0
- loop_memory/cli/commands/diag.py +346 -0
- loop_memory/cli/commands/graph.py +21 -0
- loop_memory/cli/commands/hooks.py +212 -0
- loop_memory/cli/commands/read.py +362 -0
- loop_memory/cli/commands/serve.py +147 -0
- loop_memory/cli/commands/write.py +138 -0
- loop_memory/cli/main.py +115 -0
- loop_memory/engine/__init__.py +0 -0
- loop_memory/engine/loop.py +247 -0
- loop_memory/engine/reflect.py +89 -0
- loop_memory/examples/__init__.py +0 -0
- loop_memory/examples/demo.py +39 -0
- loop_memory/export/__init__.py +39 -0
- loop_memory/export/memory_md.py +629 -0
- loop_memory/graph/__init__.py +0 -0
- loop_memory/graph/build.py +259 -0
- loop_memory/graph/extract.py +197 -0
- loop_memory/ingest/__init__.py +0 -0
- loop_memory/ingest/loader.py +782 -0
- loop_memory/ingest/pipeline.py +458 -0
- loop_memory/jobs/__init__.py +0 -0
- loop_memory/jobs/cognitive.py +353 -0
- loop_memory/jobs/compact.py +371 -0
- loop_memory/jobs/consolidate.py +95 -0
- loop_memory/jobs/contradiction.py +281 -0
- loop_memory/jobs/evolution.py +2021 -0
- loop_memory/jobs/graph.py +395 -0
- loop_memory/jobs/llm_compact_pass.py +24 -0
- loop_memory/jobs/llm_consolidate.py +980 -0
- loop_memory/jobs/scheduler.py +495 -0
- loop_memory/llm/__init__.py +0 -0
- loop_memory/llm/base.py +80 -0
- loop_memory/llm/openai_adapter.py +31 -0
- loop_memory/llm/providers.py +517 -0
- loop_memory/mcp/__init__.py +804 -0
- loop_memory/memory/__init__.py +0 -0
- loop_memory/memory/types.py +199 -0
- loop_memory/privacy/__init__.py +22 -0
- loop_memory/privacy/private.py +46 -0
- loop_memory/privacy/redact.py +188 -0
- loop_memory/py.typed +0 -0
- loop_memory/sdk.py +875 -0
- loop_memory/sdk_extensions.py +384 -0
- loop_memory/security/__init__.py +20 -0
- loop_memory/security/secrets.py +464 -0
- loop_memory/serve/__init__.py +0 -0
- loop_memory/serve/app.py +506 -0
- loop_memory/serve/handlers.py +316 -0
- loop_memory/serve/routes/_shared.py +59 -0
- loop_memory/serve/routes/admin.py +970 -0
- loop_memory/serve/routes/cognitive.py +64 -0
- loop_memory/serve/routes/export.py +65 -0
- loop_memory/serve/routes/graph.py +101 -0
- loop_memory/serve/routes/insights.py +702 -0
- loop_memory/serve/routes/memories.py +435 -0
- loop_memory/serve/routes/sessions.py +75 -0
- loop_memory/serve/routes/system.py +493 -0
- loop_memory/serve/routes/wiki.py +812 -0
- loop_memory/serve/static/__init__.py +0 -0
- loop_memory/serve/static/index.html +15 -0
- loop_memory/serve/watcher.py +451 -0
- loop_memory/storage/__init__.py +5 -0
- loop_memory/storage/retrieval.py +365 -0
- loop_memory/storage/sqlite_store.py +3627 -0
- loop_memory/wiki/__init__.py +41 -0
- loop_memory/wiki/backfill.py +143 -0
- loop_memory/wiki/classifier.py +238 -0
- loop_memory/wiki/prompts.py +295 -0
- loop_memory/wiki/scope.py +227 -0
- loop_memory-0.4.0.dist-info/METADATA +627 -0
- loop_memory-0.4.0.dist-info/RECORD +84 -0
- loop_memory-0.4.0.dist-info/WHEEL +5 -0
- loop_memory-0.4.0.dist-info/entry_points.txt +2 -0
- loop_memory-0.4.0.dist-info/licenses/LICENSE +21 -0
- loop_memory-0.4.0.dist-info/top_level.txt +1 -0
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN" data-theme="light">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>循环记忆 · Loop Memory</title>
|
|
7
|
+
<meta name="description" content="Loop Memory — local-first memory loop for Codex, Claude, Hermes and OpenClaw. Capture, distill and recall your AI conversation knowledge." />
|
|
8
|
+
<link rel="stylesheet" href="static/css/app.css" />
|
|
9
|
+
<link rel="stylesheet" href="static/css/layout.css" />
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div id="app"></div>
|
|
13
|
+
<script type="module" src="static/js/main.js"></script>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""Filesystem watcher — auto-ingests **finished** transcripts.
|
|
2
|
+
|
|
3
|
+
Sister to ``loop-memory hook``. Watches a directory for transcripts
|
|
4
|
+
written by Codex CLI / Claude Code / Hermes and ingests **only when a
|
|
5
|
+
transcript is "done"**:
|
|
6
|
+
|
|
7
|
+
* its **byte size** has not grown for ``idle_seconds`` (default 60s).
|
|
8
|
+
|
|
9
|
+
We intentionally do NOT key on mtime alone: Codex desktop (and similar
|
|
10
|
+
agents) refresh the file mtime on background metadata flushes even
|
|
11
|
+
when no new content is being written. Treating those as "still being
|
|
12
|
+
written" would prevent an ingest from ever firing for long, active
|
|
13
|
+
sessions. Size-stable-for-N-seconds is the correct signal.
|
|
14
|
+
|
|
15
|
+
This means a 30-minute chat that just ended is picked up ~60 seconds
|
|
16
|
+
after the user (or the CLI's auto-save) finished writing. Active
|
|
17
|
+
typing that grows the file size every few seconds is **not** picked up,
|
|
18
|
+
but pure metadata flushes on an idle file **are**.
|
|
19
|
+
|
|
20
|
+
Already-ingested files are tracked in a small JSON ledger so re-runs
|
|
21
|
+
don't double-write.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import time
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any, Optional
|
|
32
|
+
|
|
33
|
+
from ..ingest.loader import BaseLoader
|
|
34
|
+
from ..ingest.pipeline import MemoryPipeline
|
|
35
|
+
|
|
36
|
+
log = logging.getLogger("loop_memory.watcher")
|
|
37
|
+
# Make ``watching ...`` / ``watcher settings reloaded: ...`` lines
|
|
38
|
+
# visible in the hook process log without requiring every caller to
|
|
39
|
+
# configure logging first. ``basicConfig`` is a no-op once the root
|
|
40
|
+
# logger already has a handler, so importing this module from the
|
|
41
|
+
# serve app or from tests doesn't disturb their log formatting.
|
|
42
|
+
if not logging.getLogger().handlers:
|
|
43
|
+
_level_name = os.environ.get("LOOP_MEMORY_LOG_LEVEL", "INFO").upper()
|
|
44
|
+
logging.basicConfig(
|
|
45
|
+
level=getattr(logging, _level_name, logging.INFO),
|
|
46
|
+
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _ledger_path(watch_dir: Path) -> Path:
|
|
51
|
+
return Path(watch_dir).expanduser() / ".loop_memory_seen.json"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _load_ledger(path: Path) -> dict:
|
|
55
|
+
if not path.exists():
|
|
56
|
+
return {}
|
|
57
|
+
try:
|
|
58
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
59
|
+
except Exception:
|
|
60
|
+
return {}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _save_ledger(path: Path, ledger: dict) -> None:
|
|
64
|
+
try:
|
|
65
|
+
path.write_text(json.dumps(ledger, ensure_ascii=False), encoding="utf-8")
|
|
66
|
+
except Exception:
|
|
67
|
+
log.exception("failed to persist ingest ledger at %s", path)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# Default knobs when the user has no persisted ingest settings yet.
|
|
71
|
+
# We default to 5 minutes (300s) of size-stable idle before ingesting:
|
|
72
|
+
# shorter intervals fragment long conversations into multiple partial
|
|
73
|
+
# memories, longer intervals delay recall. The user can dial this up
|
|
74
|
+
# or down from Settings → 采集频率. ``poll_seconds`` defaults to 5
|
|
75
|
+
# (vs the previous 2.0) to cut down on stat() churn on the watched
|
|
76
|
+
# directory — stat is cheap on SSD but very cheap on the order of
|
|
77
|
+
# seconds; not the order of milliseconds. The user can always tune
|
|
78
|
+
# both via the Settings drawer.
|
|
79
|
+
DEFAULT_IDLE_SECONDS = 300.0
|
|
80
|
+
DEFAULT_POLL_SECONDS = 5.0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _read_ingest_settings(store) -> tuple[float, float]:
|
|
84
|
+
"""Pull ``ingest.idle_seconds`` / ``ingest.poll_seconds`` from the
|
|
85
|
+
settings store. Missing keys fall back to module defaults.
|
|
86
|
+
|
|
87
|
+
Returning floats (not e.g. ints) keeps the math inside the loop
|
|
88
|
+
predictable: ``time.sleep(poll_seconds)`` and the idle comparison
|
|
89
|
+
both treat the value as a wall-clock duration in seconds.
|
|
90
|
+
|
|
91
|
+
A failure here is logged and falls back to defaults rather than
|
|
92
|
+
crashing the watcher — the watcher is a long-lived background
|
|
93
|
+
process and a transient DB hiccup must not kill it.
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
cfg = store.get_setting("ingest", {}) if store is not None else {}
|
|
97
|
+
except Exception:
|
|
98
|
+
cfg = {}
|
|
99
|
+
idle = float(cfg.get("idle_seconds", DEFAULT_IDLE_SECONDS))
|
|
100
|
+
poll = float(cfg.get("poll_seconds", DEFAULT_POLL_SECONDS))
|
|
101
|
+
return idle, poll
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def run_watcher(
|
|
105
|
+
loader: BaseLoader,
|
|
106
|
+
watch_dir: Path,
|
|
107
|
+
pipeline: MemoryPipeline,
|
|
108
|
+
poll_seconds: float | None = None,
|
|
109
|
+
idle_seconds: float | None = None,
|
|
110
|
+
ledger: dict | None = None,
|
|
111
|
+
on_ingest: callable | None = None,
|
|
112
|
+
store: Any = None,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Watch a directory and ingest each transcript once it has been idle
|
|
115
|
+
for ``idle_seconds``.
|
|
116
|
+
|
|
117
|
+
``ledger`` is a dict ``path → {mtime, size, ingested_at}`` used for
|
|
118
|
+
idempotency. Pass in to share state across processes, leave None
|
|
119
|
+
to use the default JSON file under ``watch_dir``.
|
|
120
|
+
|
|
121
|
+
``on_ingest`` is an optional callable invoked with no arguments
|
|
122
|
+
after a successful ingest. The serve layer hooks this to a
|
|
123
|
+
consolidator scheduler so ``realtime`` mode can fire.
|
|
124
|
+
|
|
125
|
+
``store`` is an optional :class:`MemoryStore`. When provided, the
|
|
126
|
+
watcher reads ``ingest.idle_seconds`` / ``ingest.poll_seconds``
|
|
127
|
+
from the settings table at every iteration so the user can dial
|
|
128
|
+
ingest frequency from the Settings drawer WITHOUT restarting the
|
|
129
|
+
launchd watcher process. Reads are throttled to once every
|
|
130
|
+
``SETTINGS_RELOAD_EVERY`` ticks (cheap SQLite SELECT) so we
|
|
131
|
+
don't add noticeable overhead even at a 5-second poll cadence.
|
|
132
|
+
"""
|
|
133
|
+
watch_dir = Path(watch_dir).expanduser()
|
|
134
|
+
watch_dir.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
ledger_path = _ledger_path(watch_dir)
|
|
136
|
+
if ledger is None:
|
|
137
|
+
ledger = _load_ledger(ledger_path)
|
|
138
|
+
|
|
139
|
+
# Resolve initial values from the settings store if available,
|
|
140
|
+
# otherwise fall back to the kwarg / module defaults.
|
|
141
|
+
store_idle, store_poll = _read_ingest_settings(store)
|
|
142
|
+
if idle_seconds is None:
|
|
143
|
+
idle_seconds = store_idle
|
|
144
|
+
if poll_seconds is None:
|
|
145
|
+
poll_seconds = store_poll
|
|
146
|
+
|
|
147
|
+
# Re-read settings on a wall-clock cadence instead of per-tick,
|
|
148
|
+
# so reload latency doesn't grow with ``poll_seconds``. We default
|
|
149
|
+
# to 30s: short enough that a user dialing the slider sees the
|
|
150
|
+
# effect promptly, long enough that we don't hammer SQLite.
|
|
151
|
+
SETTINGS_RELOAD_SECONDS = 30.0
|
|
152
|
+
|
|
153
|
+
log.info(
|
|
154
|
+
"watching %s for %s transcripts (idle>=%.0fs, poll=%.1fs)",
|
|
155
|
+
watch_dir, loader.source, idle_seconds, poll_seconds,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def persist():
|
|
159
|
+
_save_ledger(ledger_path, ledger)
|
|
160
|
+
|
|
161
|
+
last_reload_at = 0.0
|
|
162
|
+
try:
|
|
163
|
+
while True:
|
|
164
|
+
# Throttled settings reload on a wall-clock cadence so
|
|
165
|
+
# the reload interval is stable regardless of poll_seconds.
|
|
166
|
+
if store is not None:
|
|
167
|
+
now_mono = time.monotonic()
|
|
168
|
+
if now_mono - last_reload_at >= SETTINGS_RELOAD_SECONDS:
|
|
169
|
+
last_reload_at = now_mono
|
|
170
|
+
try:
|
|
171
|
+
new_idle, new_poll = _read_ingest_settings(store)
|
|
172
|
+
if new_idle != idle_seconds or new_poll != poll_seconds:
|
|
173
|
+
log.info(
|
|
174
|
+
"watcher settings reloaded: idle=%.0fs poll=%.1fs",
|
|
175
|
+
new_idle, new_poll,
|
|
176
|
+
)
|
|
177
|
+
idle_seconds = new_idle
|
|
178
|
+
poll_seconds = new_poll
|
|
179
|
+
except Exception:
|
|
180
|
+
log.exception("settings reload failed (using current values)")
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
files = list(loader.discover(watch_dir))
|
|
184
|
+
except FileNotFoundError:
|
|
185
|
+
files = []
|
|
186
|
+
|
|
187
|
+
now = time.time()
|
|
188
|
+
for path in files:
|
|
189
|
+
key = str(path)
|
|
190
|
+
try:
|
|
191
|
+
st = path.stat()
|
|
192
|
+
except FileNotFoundError:
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
if path.name == ".loop_memory_seen.json":
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
sig = (st.st_mtime, st.st_size)
|
|
199
|
+
prev = ledger.get(key)
|
|
200
|
+
|
|
201
|
+
# Already-ingested with same signature → skip.
|
|
202
|
+
if prev and prev.get("sig") == list(sig):
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
# Already-ingested but file changed.
|
|
206
|
+
#
|
|
207
|
+
# v2 fix (size-stable idle, not mtime-stable idle):
|
|
208
|
+
# Previously any mtime refresh — including background
|
|
209
|
+
# metadata flushes from Codex desktop that do not add
|
|
210
|
+
# any new content — would reset the idle timer, which
|
|
211
|
+
# meant a long-running active session would never
|
|
212
|
+
# trigger an ingest: every keystroke flushed the file
|
|
213
|
+
# mtime and we kept waiting.
|
|
214
|
+
#
|
|
215
|
+
# The real signal of "still being written" is *content
|
|
216
|
+
# growth* (size increasing). mtime alone is unreliable.
|
|
217
|
+
# We now track ``last_size_change_at`` and only treat a
|
|
218
|
+
# file as active when its size is actually growing.
|
|
219
|
+
if prev and prev.get("ingested_at"):
|
|
220
|
+
prev_size = prev.get("size", -1)
|
|
221
|
+
if st.st_size > prev_size:
|
|
222
|
+
# Real content growth → bump idle timestamp.
|
|
223
|
+
ledger[key] = {
|
|
224
|
+
"sig": list(sig),
|
|
225
|
+
"first_seen": prev.get("first_seen", now),
|
|
226
|
+
"last_mtime": st.st_mtime,
|
|
227
|
+
"size": st.st_size,
|
|
228
|
+
"last_size_change_at": now,
|
|
229
|
+
"ingested_at": None,
|
|
230
|
+
}
|
|
231
|
+
else:
|
|
232
|
+
# Only mtime refreshed, no new bytes. Keep the
|
|
233
|
+
# idle clock running — do NOT reset it.
|
|
234
|
+
ledger[key] = {
|
|
235
|
+
"sig": list(sig),
|
|
236
|
+
"first_seen": prev.get("first_seen", now),
|
|
237
|
+
"last_mtime": st.st_mtime,
|
|
238
|
+
"size": st.st_size,
|
|
239
|
+
# Fall back to the previous bump time so
|
|
240
|
+
# legacy ledgers without the field keep
|
|
241
|
+
# working.
|
|
242
|
+
"last_size_change_at": prev.get(
|
|
243
|
+
"last_size_change_at",
|
|
244
|
+
prev.get("first_seen", now),
|
|
245
|
+
),
|
|
246
|
+
"ingested_at": None,
|
|
247
|
+
}
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
# First observation: stamp it.
|
|
251
|
+
if not prev:
|
|
252
|
+
ledger[key] = {
|
|
253
|
+
"sig": list(sig),
|
|
254
|
+
"first_seen": now,
|
|
255
|
+
"last_mtime": st.st_mtime,
|
|
256
|
+
"size": st.st_size,
|
|
257
|
+
"last_size_change_at": now,
|
|
258
|
+
"ingested_at": None,
|
|
259
|
+
}
|
|
260
|
+
persist()
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
# Subsequent observation: only proceed if the file has
|
|
264
|
+
# been size-stable (not just mtime-stable) for
|
|
265
|
+
# ``idle_seconds``. Codex desktop touches mtime on
|
|
266
|
+
# every flush but the size only grows when new
|
|
267
|
+
# conversation content lands — that's the signal we
|
|
268
|
+
# care about.
|
|
269
|
+
last_change = ledger[key].get(
|
|
270
|
+
"last_size_change_at",
|
|
271
|
+
ledger[key].get("first_seen", now),
|
|
272
|
+
)
|
|
273
|
+
if (now - last_change) < idle_seconds:
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
# Stable and idle → ingest once.
|
|
277
|
+
try:
|
|
278
|
+
session = loader.load_one(path)
|
|
279
|
+
except Exception:
|
|
280
|
+
log.exception("loader failed on %s", path)
|
|
281
|
+
session = None
|
|
282
|
+
|
|
283
|
+
if session is not None:
|
|
284
|
+
try:
|
|
285
|
+
result = pipeline.run(session)
|
|
286
|
+
log.info(
|
|
287
|
+
"ingested %s as %s (%d summary items)",
|
|
288
|
+
path.name, session.source, len(result.summary_items),
|
|
289
|
+
)
|
|
290
|
+
if on_ingest is not None:
|
|
291
|
+
try:
|
|
292
|
+
on_ingest()
|
|
293
|
+
except Exception:
|
|
294
|
+
log.exception("on_ingest callback failed")
|
|
295
|
+
except Exception:
|
|
296
|
+
log.exception("pipeline failed on %s", path)
|
|
297
|
+
|
|
298
|
+
ledger[key] = {
|
|
299
|
+
"sig": list(sig),
|
|
300
|
+
"first_seen": prev.get("first_seen", now),
|
|
301
|
+
"last_mtime": st.st_mtime,
|
|
302
|
+
"size": st.st_size,
|
|
303
|
+
"ingested_at": now,
|
|
304
|
+
}
|
|
305
|
+
persist()
|
|
306
|
+
|
|
307
|
+
time.sleep(poll_seconds)
|
|
308
|
+
except KeyboardInterrupt:
|
|
309
|
+
log.info("watcher exiting")
|
|
310
|
+
persist()
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def run_once(
|
|
315
|
+
loader: BaseLoader,
|
|
316
|
+
watch_dir: Path,
|
|
317
|
+
pipeline: MemoryPipeline,
|
|
318
|
+
poll_seconds: float = 2.0,
|
|
319
|
+
idle_seconds: float = 0.0,
|
|
320
|
+
ledger: dict | None = None,
|
|
321
|
+
on_ingest: callable | None = None,
|
|
322
|
+
) -> dict[str, Any]:
|
|
323
|
+
"""Run a single ingest pass over ``watch_dir`` and return a summary.
|
|
324
|
+
|
|
325
|
+
Unlike :func:`run_watcher` this does NOT loop — it scans once,
|
|
326
|
+
ingests any file whose size has grown since the last successful
|
|
327
|
+
ingest (or that has been size-stable for ``idle_seconds``), and
|
|
328
|
+
returns. Used by the server-side force-ingest endpoint so a UI
|
|
329
|
+
button can trigger one batch without spawning a long-lived
|
|
330
|
+
watcher process.
|
|
331
|
+
|
|
332
|
+
Returns a dict with::
|
|
333
|
+
|
|
334
|
+
{
|
|
335
|
+
"scanned": int, # number of files seen
|
|
336
|
+
"ingested": int, # number of files successfully ingested
|
|
337
|
+
"skipped": int, # unchanged or already-ingested
|
|
338
|
+
"errors": int, # files that failed to load
|
|
339
|
+
"files": [ # per-file detail
|
|
340
|
+
{"path": str, "status": "ingested"|"skipped"|"error",
|
|
341
|
+
"summary_items": int, "error": str?}
|
|
342
|
+
],
|
|
343
|
+
}
|
|
344
|
+
"""
|
|
345
|
+
watch_dir = Path(watch_dir).expanduser()
|
|
346
|
+
watch_dir.mkdir(parents=True, exist_ok=True)
|
|
347
|
+
ledger_path = _ledger_path(watch_dir)
|
|
348
|
+
if ledger is None:
|
|
349
|
+
ledger = _load_ledger(ledger_path)
|
|
350
|
+
|
|
351
|
+
def _persist():
|
|
352
|
+
_save_ledger(ledger_path, ledger)
|
|
353
|
+
|
|
354
|
+
result: dict[str, Any] = {
|
|
355
|
+
"scanned": 0,
|
|
356
|
+
"ingested": 0,
|
|
357
|
+
"skipped": 0,
|
|
358
|
+
"errors": 0,
|
|
359
|
+
"files": [],
|
|
360
|
+
}
|
|
361
|
+
try:
|
|
362
|
+
files = list(loader.discover(watch_dir))
|
|
363
|
+
except FileNotFoundError:
|
|
364
|
+
files = []
|
|
365
|
+
|
|
366
|
+
now = time.time()
|
|
367
|
+
for path in files:
|
|
368
|
+
result["scanned"] += 1
|
|
369
|
+
key = str(path)
|
|
370
|
+
try:
|
|
371
|
+
st = path.stat()
|
|
372
|
+
except FileNotFoundError:
|
|
373
|
+
continue
|
|
374
|
+
if path.name == ".loop_memory_seen.json":
|
|
375
|
+
continue
|
|
376
|
+
|
|
377
|
+
prev = ledger.get(key)
|
|
378
|
+
prev_size = (prev or {}).get("size", -1)
|
|
379
|
+
prev_ingested = (prev or {}).get("ingested_at")
|
|
380
|
+
|
|
381
|
+
# If the file is identical to what we last ingested, skip.
|
|
382
|
+
if prev_ingested and st.st_size == prev_size:
|
|
383
|
+
result["skipped"] += 1
|
|
384
|
+
result["files"].append({"path": key, "status": "skipped"})
|
|
385
|
+
continue
|
|
386
|
+
|
|
387
|
+
# Optional idle gate: when idle_seconds > 0, only ingest if
|
|
388
|
+
# the file's size has been stable for at least that long.
|
|
389
|
+
# When idle_seconds == 0 (the default for run_once), ingest
|
|
390
|
+
# immediately as long as new content exists.
|
|
391
|
+
if idle_seconds > 0:
|
|
392
|
+
last_change = (prev or {}).get(
|
|
393
|
+
"last_size_change_at",
|
|
394
|
+
(prev or {}).get("first_seen", now),
|
|
395
|
+
)
|
|
396
|
+
if (now - last_change) < idle_seconds:
|
|
397
|
+
result["skipped"] += 1
|
|
398
|
+
result["files"].append({
|
|
399
|
+
"path": key, "status": "skipped",
|
|
400
|
+
"reason": "not_idle_long_enough",
|
|
401
|
+
})
|
|
402
|
+
continue
|
|
403
|
+
|
|
404
|
+
# Try to load + ingest.
|
|
405
|
+
try:
|
|
406
|
+
session = loader.load_one(path)
|
|
407
|
+
except Exception as e:
|
|
408
|
+
log.exception("loader failed on %s", path)
|
|
409
|
+
result["errors"] += 1
|
|
410
|
+
result["files"].append({
|
|
411
|
+
"path": key, "status": "error",
|
|
412
|
+
"error": f"{type(e).__name__}: {e}",
|
|
413
|
+
})
|
|
414
|
+
continue
|
|
415
|
+
|
|
416
|
+
if session is None:
|
|
417
|
+
result["skipped"] += 1
|
|
418
|
+
result["files"].append({"path": key, "status": "skipped"})
|
|
419
|
+
continue
|
|
420
|
+
|
|
421
|
+
try:
|
|
422
|
+
pipe_result = pipeline.run(session)
|
|
423
|
+
n_items = len(pipe_result.summary_items)
|
|
424
|
+
result["ingested"] += 1
|
|
425
|
+
result["files"].append({
|
|
426
|
+
"path": key, "status": "ingested",
|
|
427
|
+
"summary_items": n_items,
|
|
428
|
+
})
|
|
429
|
+
ledger[key] = {
|
|
430
|
+
"sig": [st.st_mtime, st.st_size],
|
|
431
|
+
"first_seen": (prev or {}).get("first_seen", now),
|
|
432
|
+
"last_mtime": st.st_mtime,
|
|
433
|
+
"size": st.st_size,
|
|
434
|
+
"last_size_change_at": now,
|
|
435
|
+
"ingested_at": now,
|
|
436
|
+
}
|
|
437
|
+
_persist()
|
|
438
|
+
if on_ingest is not None:
|
|
439
|
+
try:
|
|
440
|
+
on_ingest()
|
|
441
|
+
except Exception:
|
|
442
|
+
log.exception("on_ingest callback failed")
|
|
443
|
+
except Exception as e:
|
|
444
|
+
log.exception("pipeline failed on %s", path)
|
|
445
|
+
result["errors"] += 1
|
|
446
|
+
result["files"].append({
|
|
447
|
+
"path": key, "status": "error",
|
|
448
|
+
"error": f"{type(e).__name__}: {e}",
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
return result
|