bookmark-research 0.5.0-beta.1

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 (106) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.codex-plugin/plugin.json +46 -0
  3. package/LICENSE +674 -0
  4. package/README.md +128 -0
  5. package/README.zh.md +126 -0
  6. package/bin/bookmark-research.js +30 -0
  7. package/config/providers.json +98 -0
  8. package/docs/bookmark-research-architecture.md +124 -0
  9. package/docs/cli-launcher-plan.md +58 -0
  10. package/docs/details.en.md +272 -0
  11. package/docs/details.md +93 -0
  12. package/docs/harness-compatibility.md +304 -0
  13. package/docs/harness-sources.json +342 -0
  14. package/docs/host-validation.json +298 -0
  15. package/docs/install-design-0.2.0.md +57 -0
  16. package/docs/installation.en.md +197 -0
  17. package/docs/installation.md +316 -0
  18. package/docs/instructions.en.md +42 -0
  19. package/docs/instructions.md +42 -0
  20. package/docs/mcp-aggregation-0.2.0.md +92 -0
  21. package/docs/multi-host-installation-plan.md +163 -0
  22. package/docs/prompt-reference.en.md +104 -0
  23. package/docs/prompt-reference.md +104 -0
  24. package/docs/provider-smoke-0.2.0.json +235 -0
  25. package/docs/release-notes-0.5.0-beta.1.md +33 -0
  26. package/docs/research-0.2.0.md +140 -0
  27. package/docs/research-sources-0.2.0.json +9360 -0
  28. package/docs/user-guide.en.md +74 -0
  29. package/docs/user-guide.md +74 -0
  30. package/docs/validation-0.2.0.md +126 -0
  31. package/docs/validation-0.4.0.md +154 -0
  32. package/docs/wiki-quality.md +105 -0
  33. package/docs/wiki-quality.zh.md +105 -0
  34. package/hosts/claude/runtime.js +13 -0
  35. package/hosts/codex/delegate.md +14 -0
  36. package/hosts/codex/prepare.py +61 -0
  37. package/hosts/dsh/plugin.js +36 -0
  38. package/hosts/dsh/runtime.js +14 -0
  39. package/hosts/dsh/workflow-call.py +45 -0
  40. package/hosts/pi/register-workflow.py +72 -0
  41. package/hosts/pi/runtime.js +18 -0
  42. package/hosts/shared/research-call.py +76 -0
  43. package/hosts/shared/research-flow.js +208 -0
  44. package/hosts/shared/workflow.json +15 -0
  45. package/install.sh +310 -0
  46. package/package.json +12 -0
  47. package/scripts/build_zip.py +143 -0
  48. package/scripts/export_bundle.py +496 -0
  49. package/scripts/host_assets.py +70 -0
  50. package/scripts/host_clients.py +196 -0
  51. package/scripts/host_install.py +277 -0
  52. package/scripts/install.py +593 -0
  53. package/scripts/launcher.py +186 -0
  54. package/scripts/verify_fixture.py +220 -0
  55. package/scripts/verify_live.py +62 -0
  56. package/scripts/verify_quality.py +87 -0
  57. package/skills/bookmark-research/SKILL.md +80 -0
  58. package/skills/bookmark-research/agents/openai.yaml +3 -0
  59. package/skills/bookmark-research/references/cli.md +150 -0
  60. package/skills/bookmark-research/references/deep-research.md +164 -0
  61. package/skills/bookmark-research/references/github-and-sync.md +30 -0
  62. package/skills/bookmark-research/references/host-workflows.md +101 -0
  63. package/skills/bookmark-research/references/package-semantics.md +78 -0
  64. package/skills/bookmark-research/references/research-methods.md +51 -0
  65. package/skills/bookmark-research/references/research-services.md +72 -0
  66. package/skills/bookmark-research/references/research-workflow.md +57 -0
  67. package/skills/bookmark-research/references/settings-and-archive.md +104 -0
  68. package/skills/bookmark-research/references/source-lifecycle.md +75 -0
  69. package/skills/bookmark-research/references/wiki-and-evaluation.md +63 -0
  70. package/skills/bookmark-research/references/zh/cli.md +150 -0
  71. package/skills/bookmark-research/references/zh/deep-research.md +164 -0
  72. package/skills/bookmark-research/references/zh/github-and-sync.md +30 -0
  73. package/skills/bookmark-research/references/zh/host-workflows.md +101 -0
  74. package/skills/bookmark-research/references/zh/package-semantics.md +78 -0
  75. package/skills/bookmark-research/references/zh/research-methods.md +51 -0
  76. package/skills/bookmark-research/references/zh/research-services.md +70 -0
  77. package/skills/bookmark-research/references/zh/research-workflow.md +57 -0
  78. package/skills/bookmark-research/references/zh/settings-and-archive.md +104 -0
  79. package/skills/bookmark-research/references/zh/skill-guide.md +75 -0
  80. package/skills/bookmark-research/references/zh/source-lifecycle.md +75 -0
  81. package/skills/bookmark-research/references/zh/wiki-and-evaluation.md +63 -0
  82. package/src/archive.py +302 -0
  83. package/src/bookmark_index.py +913 -0
  84. package/src/cli.py +435 -0
  85. package/src/credentials.py +82 -0
  86. package/src/evaluation.py +337 -0
  87. package/src/jina.py +96 -0
  88. package/src/mcp_server.py +641 -0
  89. package/src/onboarding.py +352 -0
  90. package/src/provider_adapters.py +152 -0
  91. package/src/readiness.py +283 -0
  92. package/src/remote_mcp.py +295 -0
  93. package/src/research.py +1334 -0
  94. package/src/research_coverage.py +160 -0
  95. package/src/research_services.py +684 -0
  96. package/src/routing.py +202 -0
  97. package/src/search_results.py +244 -0
  98. package/src/service_http.py +139 -0
  99. package/src/settings.py +247 -0
  100. package/src/source_inputs.py +201 -0
  101. package/src/source_manager.py +348 -0
  102. package/src/source_watcher.py +68 -0
  103. package/src/textio.py +9 -0
  104. package/src/tui.py +586 -0
  105. package/src/web_search.py +603 -0
  106. package/src/wiki.py +383 -0
@@ -0,0 +1,348 @@
1
+ """Stable source registration, managed snapshots and conservative live refresh."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import sqlite3
7
+ import tempfile
8
+ import time
9
+ import uuid
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+
13
+ from bookmark_index import _dump, _inside, _string
14
+ from source_inputs import fingerprint, git_busy, git_directory, hashes, read_input
15
+
16
+
17
+ SCHEMA = (
18
+ """CREATE TABLE IF NOT EXISTS source_records (
19
+ source_id TEXT PRIMARY KEY REFERENCES sources(source_id), record_json TEXT NOT NULL)""",
20
+ """CREATE TABLE IF NOT EXISTS source_aliases (
21
+ source_id TEXT NOT NULL REFERENCES sources(source_id), input_path TEXT NOT NULL,
22
+ PRIMARY KEY(source_id,input_path))""",
23
+ """CREATE TABLE IF NOT EXISTS source_snapshots (
24
+ source_id TEXT NOT NULL REFERENCES sources(source_id), version_id TEXT NOT NULL,
25
+ created_at TEXT NOT NULL, snapshot_path TEXT NOT NULL, manifest_json TEXT NOT NULL,
26
+ PRIMARY KEY(source_id,version_id))""",
27
+ )
28
+
29
+
30
+ class SourceManager:
31
+ def __init__(self, index, clock=time.time, debounce=2.0, deletion_grace=5.0):
32
+ self.index, self.connection = index, index.connection
33
+ self.clock, self.debounce, self.deletion_grace = clock, debounce, deletion_grace
34
+ self.storage = index.db_path.with_name(index.db_path.name + ".sources") if index.db_path else None
35
+ with index.transaction():
36
+ for statement in SCHEMA:
37
+ self.connection.execute(statement)
38
+
39
+ def _now(self):
40
+ return datetime.fromtimestamp(self.clock(), timezone.utc).isoformat()
41
+
42
+ def _record(self, source_id):
43
+ row = self.connection.execute("SELECT record_json FROM source_records WHERE source_id=?", (source_id,)).fetchone()
44
+ if row:
45
+ return json.loads(row[0])
46
+ source = self.index._source(source_id)
47
+ # Existing installations retain their original refresh/partial behavior.
48
+ return {"mode": "live", "completeness": "partial", "input_path": source["package_path"],
49
+ "input_kind": "directory", "state": "unchecked", "legacy": True,
50
+ "observed_hashes": {}, "pending_files": [], "version_id": None,
51
+ "snapshot_path": None, "checked_at": None, "error": None}
52
+
53
+ def _save(self, source_id, record):
54
+ self.connection.execute("""INSERT INTO source_records VALUES(?,?)
55
+ ON CONFLICT(source_id) DO UPDATE SET record_json=excluded.record_json""", (source_id, _dump(record)))
56
+
57
+ def _identity(self, path, source_id):
58
+ if source_id is not None:
59
+ _string(source_id, "source_id", True)
60
+ if len(source_id) > 512:
61
+ raise ValueError("source_id exceeds 512 characters")
62
+ return source_id
63
+ matches = {row[0] for row in self.connection.execute("""SELECT source_id FROM source_aliases WHERE input_path=?
64
+ UNION SELECT source_id FROM sources WHERE package_path=?
65
+ UNION SELECT source_id FROM source_snapshots WHERE snapshot_path=?""", (str(path), str(path), str(path)))}
66
+ if len(matches) > 1:
67
+ raise ValueError("Multiple sources use this path; provide source_id")
68
+ return next(iter(matches)) if matches else "source-" + uuid.uuid4().hex[:16]
69
+
70
+ def _section_paths(self, source_id):
71
+ return {row[0]: row[1] for row in self.connection.execute(
72
+ "SELECT section_id,file_path FROM sections WHERE source_id=?", (source_id,))}
73
+
74
+ @staticmethod
75
+ def _write_files(root, files):
76
+ for relative, raw in files.items():
77
+ path = root / relative
78
+ if not _inside(path.resolve(), root.resolve()) or path == root:
79
+ raise ValueError("Snapshot file escaped its directory")
80
+ path.parent.mkdir(parents=True, exist_ok=True)
81
+ path.write_bytes(raw)
82
+
83
+ def _bindings(self, source_id):
84
+ return {
85
+ "anchors": [dict(row) for row in self.connection.execute(
86
+ "SELECT section_id,canonical_id FROM sections WHERE source_id=? AND is_anchor=1 ORDER BY section_id", (source_id,))],
87
+ "nodes": [dict(row) for row in self.connection.execute("""SELECT canvas_path,node_id,file_path,section_id
88
+ FROM nodes WHERE source_id=? AND node_type='file' ORDER BY canvas_path,node_id""", (source_id,))],
89
+ }
90
+
91
+ @staticmethod
92
+ def _imported_snapshot(prepared):
93
+ path = Path(prepared["path"])
94
+ manifest_path = path.parent / "manifest.json"
95
+ if prepared["kind"] != "directory" or path.name != "package" or not manifest_path.is_file():
96
+ return None
97
+ if manifest_path.is_symlink() or manifest_path.stat().st_size > 8000000:
98
+ raise ValueError("Invalid managed snapshot manifest")
99
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
100
+ if not isinstance(manifest, dict):
101
+ raise ValueError("Invalid managed snapshot manifest")
102
+ if manifest.get("format") != "bookmark-research-snapshot":
103
+ return None
104
+ content = {"files": prepared["hashes"], "bindings": manifest.get("bindings")}
105
+ if (manifest.get("files") != prepared["hashes"] or
106
+ manifest.get("version_id") != "v-" + fingerprint(content)):
107
+ raise ValueError("Managed snapshot integrity check failed")
108
+ return manifest
109
+
110
+ def _restore_bindings(self, source_id, bindings):
111
+ """Preserve earlier layout mappings when a partial export renamed a card.
112
+
113
+ Raw files remain byte-for-byte unchanged. Only validated, in-source
114
+ associations from the hash-checked snapshot manifest are restored.
115
+ """
116
+ if not isinstance(bindings, dict) or set(bindings) != {"anchors", "nodes"}:
117
+ raise ValueError("Invalid snapshot bindings")
118
+ for key, expected in (("anchors", {"section_id", "canonical_id"}),
119
+ ("nodes", {"canvas_path", "node_id", "file_path", "section_id"})):
120
+ if not isinstance(bindings[key], list) or len(bindings[key]) > 10000:
121
+ raise ValueError("Invalid snapshot binding list")
122
+ seen = set()
123
+ for entry in bindings[key]:
124
+ if not isinstance(entry, dict) or set(entry) != expected:
125
+ raise ValueError("Invalid snapshot binding entry")
126
+ for field, value in entry.items():
127
+ if value is None and (field == "canonical_id" or key == "nodes" and field == "section_id"):
128
+ continue
129
+ _string(value, "snapshot binding " + field, True)
130
+ identity = entry["section_id"] if key == "anchors" else (entry["canvas_path"], entry["node_id"])
131
+ if identity in seen:
132
+ raise ValueError("Duplicate snapshot binding")
133
+ seen.add(identity)
134
+ sections = {row["section_id"]: dict(row) for row in self.connection.execute(
135
+ "SELECT * FROM sections WHERE source_id=?", (source_id,))}
136
+ for anchor in bindings["anchors"]:
137
+ sid, target = anchor["section_id"], anchor["canonical_id"]
138
+ if sid not in sections or not sections[sid]["is_anchor"] or (target is not None and
139
+ (target not in sections or sections[target]["is_anchor"] or sections[target]["section_type"] != "permanent")):
140
+ raise ValueError("Snapshot anchor target is not a primary section")
141
+ self.connection.execute("UPDATE sections SET canonical_id=? WHERE source_id=? AND section_id=?", (target, source_id, sid))
142
+ for node in bindings["nodes"]:
143
+ sid = node["section_id"]
144
+ if sid is not None and sid not in sections:
145
+ raise ValueError("Snapshot node target is not in this source")
146
+ row = self.connection.execute("SELECT file_path FROM nodes WHERE source_id=? AND canvas_path=? AND node_id=? AND node_type='file'",
147
+ (source_id, node["canvas_path"], node["node_id"])).fetchone()
148
+ if row is None or row[0] != node["file_path"]:
149
+ raise ValueError("Snapshot node binding does not match its raw file")
150
+ self.connection.execute("UPDATE nodes SET section_id=? WHERE source_id=? AND canvas_path=? AND node_id=?",
151
+ (sid, source_id, node["canvas_path"], node["node_id"]))
152
+
153
+ def sync(self, package_path, source_id=None, mode=None, completeness=None):
154
+ path = Path(package_path).expanduser().resolve()
155
+ if mode not in (None, "snapshot", "live") or completeness not in (None, "partial", "complete"):
156
+ raise ValueError("Use mode=snapshot/live and completeness=partial/complete")
157
+ if self.storage is None:
158
+ raise ValueError("Managed sources require a persistent database")
159
+ if path.is_dir() and (_inside(self.index.db_path, path) or _inside(self.storage, path)):
160
+ raise ValueError("Keep the database and snapshots outside the imported package")
161
+ with self.index.transaction():
162
+ source_id = self._identity(path, source_id)
163
+ exists = self.connection.execute("SELECT 1 FROM sources WHERE source_id=?", (source_id,)).fetchone()
164
+ previous = self._record(source_id) if exists else None
165
+ prepared = read_input(path, self._section_paths(source_id))
166
+ imported = self._imported_snapshot(prepared)
167
+ if imported and mode == "live":
168
+ raise ValueError("Managed snapshots are immutable; use mode=snapshot")
169
+ mode = mode or ("snapshot" if imported else previous["mode"] if previous else
170
+ "live" if prepared["kind"] == "directory" and git_directory(path) else "snapshot")
171
+ if mode == "live" and prepared["kind"] != "directory":
172
+ raise ValueError("Live sources require a directory; pass mode=snapshot to replace this source with an export")
173
+ if prepared["single_section"]:
174
+ if completeness == "complete":
175
+ raise ValueError("A single card is a partial export; use completeness=partial")
176
+ completeness = "partial"
177
+ completeness = completeness or (previous["completeness"] if previous else "partial")
178
+ return self._ingest(prepared, source_id, mode, completeness, previous)
179
+
180
+ def _ingest(self, prepared, source_id, mode, completeness, previous):
181
+ if mode == "live" and git_busy(prepared["path"]):
182
+ raise ValueError("Git is updating this directory; retry after its lock is released")
183
+ imported = self._imported_snapshot(prepared)
184
+ old_source = self.index._source(source_id) if previous else None
185
+ old_files = {row["file_path"]: dict(row) for row in self.connection.execute(
186
+ "SELECT * FROM files WHERE source_id=?", (source_id,))}
187
+ if self.storage.is_symlink():
188
+ raise ValueError("Snapshot storage must not be a symlink")
189
+ self.storage.mkdir(parents=True, exist_ok=True)
190
+ with tempfile.TemporaryDirectory(prefix=".ingest-", dir=self.storage) as staging:
191
+ incoming = Path(staging) / "incoming"
192
+ incoming.mkdir()
193
+ self._write_files(incoming, prepared["files"])
194
+ summary = self.index.sync(incoming, source_id, completeness=completeness)
195
+ if imported:
196
+ self._restore_bindings(source_id, imported["bindings"])
197
+ summary["warnings"].append("Restored recorded snapshot relationships; raw files were not rewritten")
198
+ elif completeness == "complete" and any(warning.startswith("Unresolved") for warning in summary["warnings"]):
199
+ raise ValueError("Complete export contains unresolved references; retain the previous index until all files agree")
200
+ files, recovered = {}, []
201
+ for row in self.connection.execute("SELECT * FROM files WHERE source_id=? ORDER BY file_path", (source_id,)).fetchall():
202
+ name = row["file_path"]
203
+ raw = prepared["files"].get(name)
204
+ if raw is None:
205
+ root = Path(previous["snapshot_path"] or old_source["package_path"])
206
+ candidate = root / name
207
+ if candidate.is_file() and not candidate.is_symlink() and _inside(candidate.resolve(), root.resolve()):
208
+ raw = candidate.read_bytes()
209
+ if raw is None or hashlib.sha256(raw).hexdigest() != old_files[name]["sha256"]:
210
+ if previous.get("snapshot_path"):
211
+ raise ValueError("Saved snapshot is missing or changed: " + name)
212
+ # Legacy SQLite retained complete JSON, but not its
213
+ # original whitespace. Record this recovery explicitly.
214
+ raw = row["raw_json"].encode("utf-8")
215
+ recovered.append(name)
216
+ self.connection.execute("UPDATE files SET sha256=?,revision=revision+1 WHERE source_id=? AND file_path=?",
217
+ (hashlib.sha256(raw).hexdigest(), source_id, name))
218
+ files[name] = raw
219
+ content = {"files": hashes(files), "bindings": self._bindings(source_id)}
220
+ version_id = "v-" + fingerprint(content)
221
+ source_directory = self.storage / hashlib.sha256(source_id.encode("utf-8")).hexdigest()
222
+ destination = source_directory / version_id
223
+ snapshot_path = destination / "package"
224
+ manifest = {"format": "bookmark-research-snapshot", "schema_version": 1,
225
+ "source_id": source_id, "version_id": version_id, "created_at": self._now(),
226
+ "input_path": prepared["path"], "input_kind": prepared["kind"], "completeness": completeness,
227
+ "provided_files": sorted(prepared["files"]), "recovered_legacy_files": recovered, **content}
228
+ staged_snapshot = Path(staging) / "snapshot"
229
+ self._write_files(staged_snapshot / "package", files)
230
+ (staged_snapshot / "manifest.json").write_bytes(_dump(manifest).encode("utf-8"))
231
+ if source_directory.is_symlink() or destination.is_symlink():
232
+ raise ValueError("Managed snapshot directories must not be symlinks")
233
+ source_directory.mkdir(parents=True, exist_ok=True)
234
+ if destination.exists():
235
+ saved = read_input(snapshot_path)
236
+ existing = self._imported_snapshot(saved)
237
+ if existing is None or existing["version_id"] != version_id:
238
+ raise ValueError("Existing managed snapshot integrity check failed")
239
+ else:
240
+ os.replace(staged_snapshot, destination)
241
+ # Recheck the source after staging. A Git pull or editor save in
242
+ # flight must not publish a mixture of file generations.
243
+ if mode == "live" and (git_busy(prepared["path"]) or
244
+ read_input(prepared["path"])["fingerprint"] != prepared["fingerprint"]):
245
+ raise ValueError("Source changed during import; retry after its files settle")
246
+ self.connection.execute("INSERT OR IGNORE INTO source_snapshots VALUES(?,?,?,?,?)",
247
+ (source_id, version_id, manifest["created_at"], str(snapshot_path), _dump(manifest)))
248
+ if previous:
249
+ self.connection.execute("INSERT OR IGNORE INTO source_aliases VALUES(?,?)", (source_id, previous["input_path"]))
250
+ self.connection.execute("INSERT OR IGNORE INTO source_aliases VALUES(?,?)", (source_id, prepared["path"]))
251
+ record = {"mode": mode, "completeness": completeness, "input_path": prepared["path"],
252
+ "input_kind": prepared["kind"], "version_id": version_id, "snapshot_path": str(snapshot_path),
253
+ "observed_hashes": prepared["hashes"], "checked_at": self._now(),
254
+ "state": "snapshot" if mode == "snapshot" else "current", "error": None,
255
+ "pending_files": [], "pending_fingerprint": None, "pending_since": None,
256
+ "retained_missing_files": summary["retained_missing_files"], "legacy": False}
257
+ self._save(source_id, record)
258
+ active_path = str(snapshot_path) if mode == "snapshot" else prepared["path"]
259
+ self.connection.execute("UPDATE sources SET package_path=? WHERE source_id=?", (active_path, source_id))
260
+ if recovered:
261
+ summary["warnings"].append("Recovered legacy JSON from SQLite without original whitespace: " + ", ".join(recovered))
262
+ summary.update(package_path=active_path, input_path=prepared["path"], input_kind=prepared["kind"],
263
+ mode=mode, completeness=completeness, version_id=version_id,
264
+ snapshot_path=str(snapshot_path), state=record["state"], performed=True)
265
+ return summary
266
+
267
+ def _observation(self, source_id, record, state, **fields):
268
+ record.update(state=state, checked_at=self._now(), **fields)
269
+ self._save(source_id, record)
270
+ return {"source_id": source_id, "mode": record["mode"], "state": state, "performed": False,
271
+ "changed_files": 0, "items_inserted": 0, "items_updated": 0, "items_deleted": 0,
272
+ "pending_files": record.get("pending_files", []), "error": record.get("error"),
273
+ "version_id": record.get("version_id"), "checked_at": record["checked_at"]}
274
+
275
+ def refresh(self, source_id, force=True):
276
+ record = self._record(source_id)
277
+ if record["mode"] == "snapshot":
278
+ return {"source_id": source_id, "mode": "snapshot", "state": "snapshot", "performed": False,
279
+ "changed_files": 0, "version_id": record["version_id"], "snapshot_path": record["snapshot_path"]}
280
+ try:
281
+ with self.index.transaction():
282
+ record = self._record(source_id)
283
+ if record["mode"] == "snapshot":
284
+ return self.refresh(source_id, force)
285
+ if git_busy(record["input_path"]):
286
+ return self._observation(source_id, record, "pending", error="Git is updating the source",
287
+ pending_fingerprint=None, pending_since=None)
288
+ prepared = read_input(record["input_path"])
289
+ before = record["observed_hashes"] or {row[0]: row[1] for row in self.connection.execute(
290
+ "SELECT file_path,sha256 FROM files WHERE source_id=?", (source_id,))}
291
+ after = prepared["hashes"]
292
+ pending = sorted(name for name in before.keys() | after.keys() if before.get(name) != after.get(name))
293
+ if not pending:
294
+ return self._observation(source_id, record, "current", observed_hashes=after, pending_files=[],
295
+ pending_fingerprint=None, pending_since=None, error=None)
296
+ deletion = bool(before.keys() - after.keys()) and record["completeness"] == "complete"
297
+ delay = self.deletion_grace if deletion else self.debounce
298
+ same = record.get("pending_fingerprint") == prepared["fingerprint"]
299
+ since = record.get("pending_since") if same else self.clock()
300
+ if not (force and not deletion) and (since is None or self.clock() - since < delay):
301
+ return self._observation(source_id, record, "pending", pending_files=pending,
302
+ pending_fingerprint=prepared["fingerprint"], pending_since=since, error=None)
303
+ return self._ingest(prepared, source_id, "live", record["completeness"], record)
304
+ except (ValueError, OSError, sqlite3.Error) as exc:
305
+ with self.index.transaction():
306
+ record = self._record(source_id)
307
+ unavailable = not Path(record["input_path"]).is_dir() or "No protocol" in str(exc)
308
+ return self._observation(source_id, record, "unavailable" if unavailable else "error", error=str(exc)[:2000],
309
+ pending_fingerprint=None, pending_since=None)
310
+
311
+ def status(self, source_id=None):
312
+ result = self.index.status(source_id)
313
+ if source_id is None:
314
+ result["source_schema_version"] = 1
315
+ result["sources"] = [self.status(row["source_id"]) for row in result["sources"]]
316
+ return result
317
+ record = self._record(source_id)
318
+ visible = {key: value for key, value in record.items() if key not in ("observed_hashes", "pending_fingerprint", "pending_since")}
319
+ checked = record.get("checked_at")
320
+ if record["mode"] == "live" and record["state"] == "current" and (not checked or
321
+ self.clock() - datetime.fromisoformat(checked).timestamp() > 10):
322
+ visible["state"] = "unchecked"
323
+ visible["input_exists"] = Path(record["input_path"]).exists()
324
+ visible["snapshot_exists"] = bool(record.get("snapshot_path") and Path(record["snapshot_path"]).is_dir())
325
+ visible["aliases"] = [row[0] for row in self.connection.execute(
326
+ "SELECT input_path FROM source_aliases WHERE source_id=? ORDER BY input_path", (source_id,))]
327
+ result["source"] = visible
328
+ return result
329
+
330
+ def history(self, source_id, limit=20, offset=0):
331
+ record = self._record(source_id)
332
+ if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 100 or isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
333
+ raise ValueError("history needs limit=1..100 and a nonnegative offset")
334
+ total = self.connection.execute("SELECT count(*) FROM source_snapshots WHERE source_id=?", (source_id,)).fetchone()[0]
335
+ rows = self.connection.execute("SELECT * FROM source_snapshots WHERE source_id=? ORDER BY created_at DESC,version_id LIMIT ? OFFSET ?",
336
+ (source_id, limit, offset)).fetchall()
337
+ versions = []
338
+ for row in rows:
339
+ manifest = json.loads(row["manifest_json"])
340
+ versions.append({key: row[key] for key in ("version_id", "created_at", "snapshot_path")})
341
+ versions[-1].update(current=row["version_id"] == record["version_id"], file_count=len(manifest["files"]),
342
+ input_path=manifest["input_path"], input_kind=manifest["input_kind"], completeness=manifest["completeness"])
343
+ return {"source_id": source_id, "versions": versions, "total": total,
344
+ "next_offset": offset + limit if offset + limit < total else None}
345
+
346
+ def live_sources(self):
347
+ return [row[0] for row in self.connection.execute("SELECT source_id FROM sources ORDER BY source_id")
348
+ if self._record(row[0])["mode"] == "live"]
@@ -0,0 +1,68 @@
1
+ """Process-scoped, standard-library polling for registered live directories."""
2
+
3
+ import math
4
+ import threading
5
+ from pathlib import Path
6
+
7
+ from bookmark_index import BookmarkIndex
8
+ from source_manager import SourceManager
9
+
10
+
11
+ class SourceWatcher:
12
+ def __init__(self, db_path, interval=1.0, debounce=2.0, deletion_grace=5.0, on_change=None):
13
+ for value in (interval, debounce, deletion_grace):
14
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0:
15
+ raise ValueError("Watcher intervals must be finite positive seconds")
16
+ self.db_path = Path(db_path)
17
+ self.interval, self.debounce, self.deletion_grace = interval, debounce, deletion_grace
18
+ self.on_change = on_change
19
+ self.stop_event, self.ready = threading.Event(), threading.Event()
20
+ self.thread = None
21
+ self.last_error = None
22
+
23
+ def start(self):
24
+ if self.thread is None:
25
+ self.thread = threading.Thread(target=self.run, name="bookmark-source-watch", daemon=True)
26
+ self.thread.start()
27
+ return self
28
+
29
+ def close(self):
30
+ self.stop_event.set()
31
+ if self.thread is not None:
32
+ self.thread.join(timeout=10)
33
+
34
+ def status(self):
35
+ return {"running": bool(self.thread and self.thread.is_alive() and not self.stop_event.is_set()),
36
+ "mechanism": "poll", "interval_seconds": self.interval,
37
+ "debounce_seconds": self.debounce, "deletion_grace_seconds": self.deletion_grace,
38
+ "error": self.last_error, "lifetime": "host_process"}
39
+
40
+ def run(self):
41
+ previous = {}
42
+ try:
43
+ # Each monitor owns its connection; it never shares SQLite objects
44
+ # with the MCP request thread or another host process.
45
+ with BookmarkIndex(self.db_path) as index:
46
+ manager = SourceManager(index, debounce=self.debounce, deletion_grace=self.deletion_grace)
47
+ self.ready.set()
48
+ while not self.stop_event.is_set():
49
+ try:
50
+ for source_id in manager.live_sources():
51
+ if self.stop_event.is_set():
52
+ break
53
+ result = manager.refresh(source_id, force=False)
54
+ signature = (result["state"], result.get("version_id"),
55
+ tuple(result.get("pending_files", [])), result.get("error"))
56
+ if self.on_change is not None and signature != previous.get(source_id):
57
+ self.on_change(result)
58
+ previous[source_id] = signature
59
+ self.last_error = None
60
+ except Exception as exc:
61
+ # A locked database or temporary I/O failure is retried
62
+ # on the next tick, without terminating tool service.
63
+ self.last_error = type(exc).__name__ + ": " + str(exc)[:1000]
64
+ self.stop_event.wait(self.interval)
65
+ except Exception as exc:
66
+ self.last_error = type(exc).__name__ + ": " + str(exc)[:1000]
67
+ finally:
68
+ self.ready.set()
package/src/textio.py ADDED
@@ -0,0 +1,9 @@
1
+ """UTF-8 standard streams; Windows pipes otherwise use the ANSI code page."""
2
+
3
+ import sys
4
+
5
+
6
+ def utf8_stdio():
7
+ for stream in (sys.stdin, sys.stdout, sys.stderr):
8
+ if hasattr(stream, "reconfigure"):
9
+ stream.reconfigure(encoding="utf-8", errors="strict" if stream is sys.stdin else "backslashreplace")