bookmark-research 0.5.0-beta.7 → 0.5.0-beta.8

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.
@@ -9,7 +9,7 @@
9
9
  "source": {
10
10
  "source": "npm",
11
11
  "package": "bookmark-research",
12
- "version": "0.5.0-beta.7",
12
+ "version": "0.5.0-beta.8",
13
13
  "registry": "https://registry.npmjs.org"
14
14
  },
15
15
  "policy": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bookmark-research",
3
- "version": "0.5.0-beta.7",
3
+ "version": "0.5.0-beta.8",
4
4
  "description": "Check bookmark URLs or Canvas packages, compare sources with host-led research, and retain verifiable reports.",
5
5
  "author": {
6
6
  "name": "kwenxu"
package/README.md CHANGED
@@ -22,13 +22,13 @@ Works in **Codex, Claude Code, Pi and DSH (DeepSeek Harness)**.
22
22
 
23
23
  ## Install
24
24
 
25
- **Current beta (`0.5.0-beta.7`)**, interactive on macOS, Linux and Windows:
25
+ **Current beta (`0.5.0-beta.8`)**, interactive on macOS, Linux and Windows:
26
26
 
27
27
  ```sh
28
- npx bookmark-research@0.5.0-beta.7 install --source npm:bookmark-research@0.5.0-beta.7
28
+ npx bookmark-research@0.5.0-beta.8 install --source npm:bookmark-research@0.5.0-beta.8
29
29
  ```
30
30
 
31
- This pins both the installer and the content. The `@latest` examples below follow the default channel, which remains `0.5.0-beta.3`; `@beta` currently points to `0.5.0-beta.7`. Changing only the npx selector does not change the installer's default content source.
31
+ This pins both the installer and the content. The `@latest` examples below follow the default channel, which remains `0.5.0-beta.3`; `@beta` currently points to `0.5.0-beta.8`. Changing only the npx selector does not change the installer's default content source.
32
32
 
33
33
  **Default-channel interactive installation:**
34
34
 
package/README.zh.md CHANGED
@@ -22,13 +22,13 @@
22
22
 
23
23
  ## 安装
24
24
 
25
- **当前测试版(`0.5.0-beta.7`)**,支持 macOS、Linux、Windows 交互安装:
25
+ **当前测试版(`0.5.0-beta.8`)**,支持 macOS、Linux、Windows 交互安装:
26
26
 
27
27
  ```sh
28
- npx bookmark-research@0.5.0-beta.7 install --source npm:bookmark-research@0.5.0-beta.7
28
+ npx bookmark-research@0.5.0-beta.8 install --source npm:bookmark-research@0.5.0-beta.8
29
29
  ```
30
30
 
31
- 这条命令同时固定安装器和插件内容。下面的 `@latest` 示例使用默认渠道,目前仍为 `0.5.0-beta.3`;`@beta` 当前指向 `0.5.0-beta.7`。只改 npx 的版本选择,不会改变安装器默认下载的内容来源。
31
+ 这条命令同时固定安装器和插件内容。下面的 `@latest` 示例使用默认渠道,目前仍为 `0.5.0-beta.3`;`@beta` 当前指向 `0.5.0-beta.8`。只改 npx 的版本选择,不会改变安装器默认下载的内容来源。
32
32
 
33
33
  **默认渠道的交互安装:**
34
34
 
@@ -0,0 +1,36 @@
1
+ # Diff 审查与修正记录(2026-10-09)
2
+
3
+ 本轮审查从 `a4b7630` 之后的工作树 diff 出发,通过 CodeGraph 定位实现与调用链,交叉检查 DSH 分发、检索、研究并发和 Wiki 证据生命周期。原始改动已单独保存为 `264d910`;其发布文件与 npm `0.5.0-beta.7` 的 120 个文件逐字节一致。
4
+
5
+ DSH 修复的方向正确:发布包具有入口与 `dsh.bundle.patch` 声明,路径从安装位置解析,ESM 范围限制在 DSH 目录,环境变量按明确列表转发。用户已确认当前 DSH 跑通,旧评估报告中的失效状态属于历史记录。本轮没有重新安装或修改用户的 DSH profile。
6
+
7
+ 原始 diff 的 561 项测试通过,但交叉审查发现了并发和兼容边界,特别是来源数据保护与证据复核。以下问题均有隔离复现,并已在后续源码提交中修正。
8
+
9
+ | 优先级 | 原始 diff 的问题 | 本轮修正 |
10
+ | --- | --- | --- |
11
+ | P1 | 研究取消或生成 incomplete 报告后,慢请求仍提交结果,导致最终报告与状态文件不一致;取消后还可能追加 fallback 请求。 | 提交与 fallback 预算预留都重新检查研究为 active、操作为 pending。已关闭或确认中断的操作拒绝迟到结果。 |
12
+ | P1 | merge 在事务外判断版本相同,同步可在检查后写入独有内容,再被 merge 删除。两个未版本化的 legacy 来源也会因 `None == None` 被误合并。 | 在写事务内重新读取并校验双方有效内容版本;legacy 来源须先同步。 |
13
+ | P1 | remove/merge 提交数据库后删除原目录,可能删掉另一连接刚以相同 source ID 重新导入的快照。 | 提交前将旧快照改名为唯一隔离目录,提交后只清理该目录;异常回滚恢复旧版本且不覆盖并发新版本。 |
14
+ | P1 | 已发布证据的响应制品被修改后,`wiki acknowledge` 可重新计算 hash,把 `invalid_evidence` 清成 current。 | 持研究锁校验上一修订的证据与链接;存在 error 时拒绝复核,保留原校验基线。 |
15
+ | P2 | 并发导入最终分配的 source ID 与提前写出的 manifest ID 不一致。 | 在提交锁内分配最终 ID 后生成 manifest,回放保留该 ID。 |
16
+ | P2 | 删除引用来源后,freshness 为 unknown,但 Wiki 仍显示 current。 | 不可解析的来源产生 `source_input_unavailable`,页面进入 needs_review。 |
17
+ | P2 | 旧版 DSH 包缺少新增的嵌套 `package.json`,无法进入承诺的旧包导出兼容分支。 | 仅在文件缺失时生成 ESM manifest;符号链接和错误文件类型仍拒绝。 |
18
+ | P2 | Codex、Claude、Agent Plugins 导出没有根 `package.json`,握手版本退成 `0+unknown`。 | 读取各分发格式自己的 manifest;存在但损坏的优先 manifest 不被其他文件掩盖。 |
19
+ | P2 | 含 `%`、`_`、反斜杠的画布线索在 SQL 命中后又被丢弃。 | SQL 保留转义,结果归属使用原始查询文本。 |
20
+ | P2 | doctor 泄漏探测文件描述符、可能删除预存同名文件,并把普通文件阻断的目录路径报为可写。 | 使用自动关闭的随机临时文件;遇到非目录路径组件立即报告不可写。 |
21
+
22
+ DSH 的重装提示也已改为明确指定 profile 和版本的原生命令,避免默认 `latest` 指向旧发布。Wiki 复核记录保存本次提交的说明。
23
+
24
+ 修正快照清理后,第二轮交叉复核发现了“目录改名后、数据库提交前强制终止进程”的恢复窗口。已补启动恢复:在写事务内,根据数据库仍引用的版本及 manifest/hash 校验恢复目录,保留已有新版本,不复活已删除来源。真实子进程强制退出及后续 partial 导入均纳入验证。
25
+
26
+ 验证结果:
27
+
28
+ - 最终本地全量测试:**587 项,0 失败,3 项按条件跳过**,Python 3.9.6 / SQLite 3.43.2。跳过项为原生 Pi/DSH 安装、显式启用的 Codex/npm 集成和全宿主 npm 下载测试;不将其计作本轮宿主安装通过。
29
+ - 独立下载真实 npm beta.5,使用当前导出器生成 DSH 包,删除源副本并搬到含中文、空格和引号的路径后,Skill 和实际 MCP 调用通过。
30
+ - 当前五种导出同样在删除源副本、搬迁后完成真实 MCP initialize,全部报告 beta.7;DSH 解释器、参数和环境转发契约通过。
31
+ - 用户画布样例验收:638 个书签实例、557 个唯一 URL、122 个文件夹、5 个栏目;完整库存分页保留全部 ID,二次同步识别未修改内容,原文件 hash 未变。测试未调用网络研究服务。
32
+ - 合成研究验收正确阻止未读完来源时宣称研究完成。已发布 npm 包的 CLI help、doctor、status 冒烟通过。
33
+
34
+ 实现仍有明确边界:取消后已经返回的响应可能留下未引用制品,但不能再进入已结束研究的状态或报告。启动恢复只处理数据库仍引用且校验通过的快照;未引用的 `.removed-*` 残留保守保留,不自动回收。普通异常回滚、并发重新导入、回滚后出现新版本,以及提交前/后强制退出均有回归覆盖。
35
+
36
+ 这些审查修正属于 `264d910` 之后的源码提交,**不包含在已经发布的 npm beta.7 中**。本轮只提交并推送 Git,没有重新发布 npm,也没有移动 dist-tag。复现所用本机报告、索引和私人样例未纳入提交。
@@ -19,7 +19,9 @@
19
19
 
20
20
  发布顺序:修改源码版本并完成 CI,发布已经验证的 npm 包,确认 registry 可取得该版本,再更新 GitHub marketplace 的固定版本。未发布源码不能通过 npm 入口安装;需要先用源码入口测试。
21
21
 
22
- 测试版使用 npm 的 `beta` 标签;若创建 GitHub 版本说明,则标记为预发布。不会自动移动 npm 的 `latest` 标签。安装测试版时同时固定安装器与内容来源,例如 `npx bookmark-research@0.5.0-beta.7 install --source npm:bookmark-research@0.5.0-beta.7`。仅将 npx 改为 `@beta` 不会改变默认内容来源 `latest`;已固定的旧版安装也不会自动换成测试版。
22
+ 候选版本在 `release/` 分支同步三个版本清单并运行 CI。该分支的 registry 集成测试使用当前已发布渠道版本,源码测试、原生安装测试和 npm 打包检查仍验证候选内容;主分支的 registry 测试始终验证 marketplace 固定版本。发布通过 CI 的 npm 包并核验 registry 后,再将候选分支合入主分支,避免默认安装清单提前指向未发布版本。
23
+
24
+ 测试版使用 npm 的 `beta` 标签;若创建 GitHub 版本说明,则标记为预发布。不会自动移动 npm 的 `latest` 标签。安装测试版时同时固定安装器与内容来源,例如 `npx bookmark-research@0.5.0-beta.8 install --source npm:bookmark-research@0.5.0-beta.8`。仅将 npx 改为 `@beta` 不会改变默认内容来源 `latest`;已固定的旧版安装也不会自动换成测试版。
23
25
 
24
26
  ## 官方资料与项目实例
25
27
 
@@ -0,0 +1,34 @@
1
+ # Bookmark Research 0.5.0 beta 8
2
+
3
+ **Beta / 测试版.** Published through the npm `beta` channel; the `latest` tag remains unchanged.
4
+
5
+ ## Install
6
+
7
+ Pin both the installer and the plugin content:
8
+
9
+ ```sh
10
+ npx bookmark-research@0.5.0-beta.8 install --source npm:bookmark-research@0.5.0-beta.8
11
+ ```
12
+
13
+ Choose Codex, Claude Code, Pi or DSH in the installer. For an existing DSH profile, the released layer can also be installed directly:
14
+
15
+ ```sh
16
+ dsh plugin --profile <name> add bookmark-research@0.5.0-beta.8
17
+ ```
18
+
19
+ ## Changes
20
+
21
+ - DSH exports support legacy npm packages that lack the nested ESM manifest. Repair instructions retain the selected profile and release version. The Desktop prerequisite test now resolves actual executable shims on Windows.
22
+ - Relocated Codex, Claude Code and Agent Plugins exports report their own release version during the real MCP handshake.
23
+ - Cancelled or incomplete research tasks reject late provider results and further fallback reservations. Concurrent evidence imports retain the final source IDs in their manifests.
24
+ - Source merging checks matching, nonempty synchronized versions inside the write transaction. Source removal preserves concurrent reimports; startup recovers hash-verified snapshots left by an interrupted removal without reviving committed deletions.
25
+ - Wiki acknowledgements cannot accept invalid evidence. Unavailable sources require review, and a new acknowledgement retains its review note.
26
+ - Canvas searches preserve literal `%`, `_` and backslash characters. Storage diagnostics close probe handles, preserve existing files and report paths blocked by files.
27
+
28
+ ## Verification
29
+
30
+ Regression coverage includes research cancellation, source merge/removal races, process-exit recovery, Wiki evidence integrity, exported release identity and DSH installation prerequisites. CI runs the Python suite and npm command smoke checks on Linux, macOS and Windows, plus isolated native Codex installation, migration and update tests.
31
+
32
+ Release branches test registry integration against the currently published package while validating the candidate's source and packed runtime. After publication, the main branch verifies the newly pinned npm version. Personal canvas packages and local evaluation reports are excluded from the published package.
33
+
34
+ Install from [npm](https://www.npmjs.com/package/bookmark-research/v/0.5.0-beta.8). GitHub provides source and release notes; npm is the shared release package for all four hosts.
@@ -70,8 +70,9 @@ export function apply(ctx) {
70
70
  const missing = [cli, fileURLToPath(skillUrl)].filter((path) => !existsSync(path));
71
71
  if (missing.length > 0) {
72
72
  process.stderr.write("bookmark-research: installation is incomplete; missing " + missing.join(", ") + ".\n"
73
- + "Reinstall the plugin (for example: npx --yes --package=bookmark-research@latest bookmark-research install"
74
- + " --host dsh) or remove the stale registration that points at a deleted install directory.\n");
73
+ + "Reinstall the intended version in the affected profile: dsh plugin --profile <profile> add"
74
+ + " bookmark-research@<version> (use the Desktop app's CLI for profile desktop), or remove the stale"
75
+ + " registration that points at a deleted install directory.\n");
75
76
  return;
76
77
  }
77
78
  const env = Object.fromEntries(FORWARDED_ENV
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bookmark-research",
3
- "version": "0.5.0-beta.7",
3
+ "version": "0.5.0-beta.8",
4
4
  "description": "Check bookmark URLs or Canvas packages, compare sources with host-led research, and retain verifiable reports.",
5
5
  "license": "GPL-3.0-only",
6
6
  "repository": {
@@ -13,13 +13,15 @@ HOST_SOURCE_FILES = (
13
13
  )
14
14
 
15
15
 
16
- def read_asset(root, relative):
16
+ def read_asset(root, relative, default=None):
17
17
  """Read a curated asset without following source symlinks."""
18
18
  current = Path(root)
19
19
  for part in Path(relative).parts:
20
20
  current = current / part
21
21
  if current.is_symlink():
22
22
  raise ValueError("Source symlinks are not exported: " + relative)
23
+ if default is not None and not current.exists():
24
+ return default
23
25
  if not current.is_file():
24
26
  raise ValueError("Required host asset is missing: " + relative)
25
27
  return current.read_bytes()
@@ -62,7 +64,10 @@ def export_host_assets(source, stage, host):
62
64
  files["hosts/dsh/plugin.js"] = read_asset(source, "hosts/dsh/plugin.js")
63
65
  # Scope ESM to the entry module so a package root may stay CommonJS
64
66
  # (the npm package keeps a CommonJS bin and the pi field).
65
- files["hosts/dsh/package.json"] = read_asset(source, "hosts/dsh/package.json")
67
+ # Older releases predate the nested manifest. Supply the scope
68
+ # when absent, while still rejecting links or invalid file types.
69
+ files["hosts/dsh/package.json"] = read_asset(
70
+ source, "hosts/dsh/package.json", default=b'{"type":"module"}\n')
66
71
  files["workflows/bookmark-research/meta.json"] = (
67
72
  json.dumps(definition["meta"], ensure_ascii=False, indent=2) + "\n").encode("utf-8")
68
73
  relative = "hosts/dsh/workflow-call.py"
@@ -890,8 +890,8 @@ class BookmarkIndex:
890
890
  matches = []
891
891
  for row in found:
892
892
  haystack = ((row.get("label") or "") + "\n" + (row.get("text") or "")).casefold()
893
- for target, pattern in zip(targets, patterns):
894
- needle = pattern.strip("%").casefold()
893
+ for target in targets:
894
+ needle = target.casefold()
895
895
  if needle and needle in haystack:
896
896
  matches.append({"target": target, **row})
897
897
  return matches
package/src/cli.py CHANGED
@@ -6,6 +6,7 @@ import json
6
6
  import os
7
7
  import sqlite3
8
8
  import sys
9
+ import tempfile
9
10
  from pathlib import Path
10
11
 
11
12
  from bookmark_index import BookmarkIndex
@@ -28,19 +29,17 @@ def _writable_directory(path):
28
29
  """Report whether the nearest existing ancestor of ``path`` accepts a new file."""
29
30
  candidate = Path(path).expanduser()
30
31
  for parent in (candidate, *candidate.parents):
31
- if parent.is_dir():
32
- probe = parent / (".bookmark-research-write-probe-%d" % os.getpid())
33
- try:
34
- descriptor = os.open(str(probe), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
35
- except OSError as error:
36
- return {"path": str(candidate), "checked": str(parent), "writable": False,
37
- "error": error.strerror or str(error)}
38
- finally:
39
- try:
40
- probe.unlink()
41
- except OSError:
32
+ try:
33
+ if parent.is_dir():
34
+ with tempfile.TemporaryFile(prefix=".bookmark-research-write-probe-", dir=parent):
42
35
  pass
43
- return {"path": str(candidate), "checked": str(parent), "writable": True, "error": None}
36
+ return {"path": str(candidate), "checked": str(parent), "writable": True, "error": None}
37
+ if parent.exists() or parent.is_symlink():
38
+ return {"path": str(candidate), "checked": str(parent), "writable": False,
39
+ "error": "Existing path component is not a directory"}
40
+ except OSError as error:
41
+ return {"path": str(candidate), "checked": str(parent), "writable": False,
42
+ "error": error.strerror or str(error)}
44
43
  return {"path": str(candidate), "checked": None, "writable": False, "error": "no existing parent directory"}
45
44
 
46
45
 
package/src/mcp_server.py CHANGED
@@ -131,8 +131,8 @@ TOOL_DESCRIPTIONS = {
131
131
  "research_readiness": "Check retrieval and optional research service readiness before each new web research question. Respects cached/always/manual preferences; refresh forces checks and offline prevents network. Reports missing credentials, catalog reachability and the exact scope of verified auth separately. observed_tools establish host tool visibility, not OAuth login. test_retrieval additionally uses provider quota for a sample search/read; no professional research job is started.",
132
132
  "sync_package": "Import a directory, ZIP or single section JSON, retaining a managed snapshot outside the original. Reuse source_id for a moved or partial export of the same canvas; path aliases are remembered. New exports default to snapshot; Git directories default to live monitoring. Existing sources retain their mode. completeness=partial preserves absent cards; complete reconciles a full mirror. Single cards must be partial. Source files are never modified.",
133
133
  "source_history": "List saved source versions and their complete managed snapshot paths, including data retained from partial imports. Reimport a snapshot path to recover its data and recorded relationships. This does not refresh sources or change existing research inventories.",
134
- "source_remove": "Delete an imported source, its indexed rows and its managed snapshots. Without confirm=true this only reports what would be removed. Research tasks that froze the source keep their saved inventory but stop resolving its items, and Wiki pages citing those claims become stale; prefer source_merge for an identical duplicate.",
135
- "source_merge": "Drop a duplicate source whose current content version equals into_source_id, moving its path aliases to the survivor so a later import of the same folder updates it. Fails when the two hold different content. Without confirm=true this only reports the merge.",
134
+ "source_remove": "Delete an imported source, its indexed rows and its managed snapshots. Without confirm=true this only reports what would be removed. Research tasks that froze the source keep their saved inventory but stop resolving its items, and Wiki pages citing those claims require review; prefer source_merge for an identical duplicate.",
135
+ "source_merge": "Drop a duplicate source whose current content version equals into_source_id, moving its path aliases to the survivor so a later import of the same folder updates it. Requires matching synchronized content versions; synchronize legacy sources first. Without confirm=true this only reports the merge.",
136
136
  "search_bookmarks": "Search bookmark metadata with literal title/URL/note/tag/folder-path matching and exact SQL scopes. Every whitespace-separated term must occur; three-character and longer terms use the trigram index while shorter ones use LIKE substring matching. Targets have independent totals and pages. Report instances separately from unique_urls: a copy anchor returns another card's shared tree, so per-section totals must not be summed. limit=0 or count_only returns counts without rows; item_types=[\"folder\"] enumerates folders, including the synthetic untitled tree container marked synthetic. Canvas text cards, group labels and edge labels matching a target are reported separately in canvas_matches and never inflate total. Refresh checks live directories by default; snapshots remain usable after the original export disappears. Inspect source.state for pending changes or errors. refresh=false uses the last synchronized index, not an arbitrary historical version.",
137
137
  "get_context": "Read section headers, bookmark metadata, folder ancestry, geometric group membership and directed canvas edges. Copy anchors share their primary tree. Refresh is enabled by default.",
138
138
  "index_status": "Read source modes, original input availability, saved snapshots, pending files, last check/error, counts and local monitor status. Does not refresh or fetch webpages. unchecked means a live directory has not been checked recently.",
@@ -316,8 +316,8 @@ TOOL_DESCRIPTIONS.update({
316
316
  "research_service_attach": "Attach an independently known provider run ID without starting remote work. Can recover a missing create response or reference a run started through an existing host integration. Status remains unverified until observed.",
317
317
  "research_service_import": "Import a saved completed provider report into a research session as unreviewed external-report evidence. Original URL coverage is unchanged; read and assess cited originals separately.",
318
318
  "wiki_write": "Publish an authored topic/entity page outside source packages, with reviewed active claims, source provenance, relationships and an immutable revision. Supply the current expected_revision when updating. Pass reviewed_input_version to record that the page was checked against that already-synchronized input version, which closes source_input_changed for cited tasks at that version. Semantic support requires the named human/model review.",
319
- "wiki_acknowledge": "Re-publish a Wiki page unchanged to record that its cited sources were reviewed against their current input version, closing source_input_changed without editing content. Writes an immutable revision and preserves history; the note records who reviewed what. No network access and no semantic scoring.",
320
- "wiki_get": "Read a Wiki page or an immutable earlier revision with source/claim links resolved at current research locations. validation.status=needs_review signals changed bookmark input after the cited research; this does not automatically revise the page or refetch webpages.",
319
+ "wiki_acknowledge": "Re-publish a Wiki page unchanged to record that its cited sources were reviewed against their current input version, closing source_input_changed without editing content. Writes an immutable revision and preserves history; the note records who reviewed what. Invalid evidence or links must be repaired first, and unavailable bookmark input still requires review. No network access and no semantic scoring.",
320
+ "wiki_get": "Read a Wiki page or an immutable earlier revision with source/claim links resolved at current research locations. validation.status=needs_review signals changed or unavailable bookmark input after the cited research; this does not automatically revise the page or refetch webpages.",
321
321
  "wiki_list": "List paginated Wiki pages and current revision metadata without network access.",
322
322
  "wiki_search": "Find saved knowledge when the task needs prior research absent from the conversation. Return matching Wiki text, revision dates and validation; needs_review flags changed bookmark input. Invalidated or retracted evidence is excluded. Literal full-text retrieval, not vector search.",
323
323
  "wiki_lint": "Check Wiki evidence hashes, active claims, source review states and cross-page links. Reports semantic review declarations separately from mechanical integrity checks.",
package/src/release.py CHANGED
@@ -1,13 +1,10 @@
1
- """Single release-version source, read from the installed package manifest.
1
+ """Release identity from the installed distribution's own manifest.
2
2
 
3
- The plugin is distributed as an npm package, so ``package.json`` travels with
4
- the code at the plugin root. Reading it here keeps the MCP handshake, remote
5
- client identity and HTTP user agents on the same version as the published
6
- manifest instead of drifting across hardcoded copies.
7
-
8
- When the manifest is missing (an exotic layout, or a single file copied out of
9
- the package) the version is reported as unknown rather than guessed, so a
10
- mismatch stays visible instead of being silently papered over.
3
+ The npm, Pi and DSH packages carry ``package.json``. Codex, Claude and Agent
4
+ Plugins exports retain the release version in their native plugin manifest.
5
+ Read that identity for MCP and HTTP clients instead of hardcoding a version.
6
+ An absent or invalid manifest reports an unknown version; an invalid preferred
7
+ manifest never falls through to a different manifest that might hide damage.
11
8
  """
12
9
 
13
10
  import json
@@ -32,7 +29,12 @@ def read_manifest_version(path):
32
29
  @lru_cache(maxsize=1)
33
30
  def release_version():
34
31
  """Return this installation's manifest version."""
35
- return read_manifest_version(Path(__file__).resolve().parents[1] / "package.json")
32
+ root = Path(__file__).resolve().parents[1]
33
+ for relative in ("package.json", ".codex-plugin/plugin.json", ".claude-plugin/plugin.json", "plugin.json"):
34
+ path = root / relative
35
+ if path.exists() or path.is_symlink():
36
+ return read_manifest_version(path)
37
+ return UNKNOWN_VERSION
36
38
 
37
39
 
38
40
  def user_agent(product="bookmark-research"):
package/src/research.py CHANGED
@@ -774,6 +774,14 @@ class ResearchSessions:
774
774
  references.append({key: bookmark[key] for key in ("source_id", "section_id", "item_id")})
775
775
  return [entry["id"] for entry in entries], references
776
776
 
777
+ @classmethod
778
+ def _pending_operation(cls, state, operation_id):
779
+ cls._active(state)
780
+ operation = cls._find(state["operations"], operation_id, "operation")
781
+ if operation["status"] != "pending":
782
+ raise ValueError("Research operation is no longer pending; its late result was not committed")
783
+ return operation
784
+
777
785
  def _operation(self, research_id, operation_id, kind, parameters, cost, execute):
778
786
  operation_id = self._identifier(operation_id, "operation_id")
779
787
  path = self._path(research_id)
@@ -823,14 +831,17 @@ class ResearchSessions:
823
831
  # writes made by another process while the request was in flight survive.
824
832
  with self._task_lock(path):
825
833
  _, state = self._load(research_id)
826
- stored = next((row for row in state["operations"] if row["id"] == operation_id), None)
827
- if stored is None:
828
- raise ValueError("Research operation disappeared before it was committed")
834
+ # Cancellation and explicit interruption take effect immediately.
835
+ # A late response must not change a terminal report or revive its step.
836
+ stored = self._pending_operation(state, operation_id)
829
837
  new_sources = payload.pop("sources", [])
830
838
  committed = []
831
839
  for source in new_sources:
832
840
  source = copy.deepcopy(source)
833
841
  source["id"] = "s%s" % (len(state["sources"]) + 1)
842
+ if kind == "import_evidence":
843
+ self._write(self._artifact(path, source["manifest_file"]),
844
+ {"schema_version": 1, "kind": "host_evidence_import", "source": source})
834
845
  state["sources"].append(source)
835
846
  committed.append(source)
836
847
  stored["status"] = status
@@ -875,15 +886,14 @@ class ResearchSessions:
875
886
  # latest state under the lock instead of trusting the snapshot.
876
887
  with self._task_lock(path):
877
888
  _, latest = self._load(research_id, path)
889
+ stored = self._pending_operation(latest, operation_id)
878
890
  remaining = latest["budget"]["max_search_calls"] - latest["usage"]["search_calls"]
879
891
  chosen = jobs[:max(0, remaining)]
880
892
  if chosen:
881
893
  latest["usage"]["search_calls"] += len(chosen)
882
- stored = next((row for row in latest["operations"] if row["id"] == operation_id), None)
883
- if stored is not None:
884
- stored["reserved"]["search_calls"] = stored["reserved"].get("search_calls", 0) + len(chosen)
885
- stored["fallback_attempts"] = [{"provider": name, "target": target, "query": query}
886
- for name, target, query in chosen]
894
+ stored["reserved"]["search_calls"] = stored["reserved"].get("search_calls", 0) + len(chosen)
895
+ stored["fallback_attempts"] = [{"provider": name, "target": target, "query": query}
896
+ for name, target, query in chosen]
887
897
  self._save(path, latest)
888
898
  return chosen
889
899
 
@@ -961,6 +971,7 @@ class ResearchSessions:
961
971
  # so a concurrent writer cannot lose its usage or sources.
962
972
  with self._task_lock(path):
963
973
  _, latest = self._load(research_id, path)
974
+ stored = self._pending_operation(latest, operation_id)
964
975
  remaining = latest["budget"]["max_fetch_calls"] - latest["usage"]["fetch_calls"]
965
976
  selected, reserved = [], 0
966
977
  for name in candidates:
@@ -971,10 +982,8 @@ class ResearchSessions:
971
982
  remaining -= cost
972
983
  if selected:
973
984
  latest["usage"]["fetch_calls"] += reserved
974
- stored = next((row for row in latest["operations"] if row["id"] == operation_id), None)
975
- if stored is not None:
976
- stored["reserved"]["fetch_calls"] = stored["reserved"].get("fetch_calls", 0) + reserved
977
- stored["fallback_attempts"] = [{"provider": name, "urls": pending} for name in selected]
985
+ stored["reserved"]["fetch_calls"] = stored["reserved"].get("fetch_calls", 0) + reserved
986
+ stored["fallback_attempts"] = [{"provider": name, "urls": pending} for name in selected]
978
987
  self._save(path, latest)
979
988
  return selected
980
989
 
@@ -1060,7 +1069,7 @@ class ResearchSessions:
1060
1069
  identifiers, refs = self._source_links(current, url)
1061
1070
  if supplied_ids is not None:
1062
1071
  identifiers = supplied_ids
1063
- source = {"id": "s%s" % (len(current["sources"]) + 1), "question_id": question_id,
1072
+ source = {"question_id": question_id,
1064
1073
  "operation_id": operation_id, "url": url, "canonical_url": canonical,
1065
1074
  "provider": provenance.get("provider", "host_import"),
1066
1075
  "retrieved_at": provenance.get("retrieved_at"), "imported_at": imported_at,
@@ -1075,8 +1084,8 @@ class ResearchSessions:
1075
1084
  "inventory_ids": [] if kind == "external_report" else identifiers,
1076
1085
  "referenced_inventory_ids": (supplied_ids or []) if kind == "external_report" else [],
1077
1086
  "bookmark_refs": [] if kind == "external_report" else refs}
1078
- self._write(manifest_file, {"schema_version": 1, "kind": "host_evidence_import", "source": source})
1079
- current["sources"].append(source)
1087
+ # The commit assigns the source id and writes its manifest together.
1088
+ # The request snapshot cannot predict ids allocated by other writers.
1080
1089
  return {"status": "ok", "sources": [source], "next_action":
1081
1090
  "Read and review the saved text. An imported report is one secondary source and does not mark its referenced original pages as read."}
1082
1091
 
@@ -3,11 +3,13 @@
3
3
  import hashlib
4
4
  import json
5
5
  import os
6
+ import re
6
7
  import shutil
7
8
  import sqlite3
8
9
  import tempfile
9
10
  import time
10
11
  import uuid
12
+ from contextlib import contextmanager
11
13
  from datetime import datetime, timezone
12
14
  from pathlib import Path
13
15
 
@@ -36,6 +38,60 @@ class SourceManager:
36
38
  with index.transaction():
37
39
  for statement in SCHEMA:
38
40
  self.connection.execute(statement)
41
+ self._recover_retired_snapshots()
42
+
43
+ def _recover_retired_snapshots(self):
44
+ """Recover a removal interrupted before commit, using surviving DB references."""
45
+ if self.storage is None or self.storage.is_symlink() or not self.storage.is_dir():
46
+ return
47
+ retired = {}
48
+ for directory in self.storage.iterdir():
49
+ match = re.fullmatch(r"\.removed-([0-9a-f]{64})-[0-9a-f]{32}", directory.name)
50
+ if match and not directory.is_symlink() and directory.is_dir():
51
+ retired.setdefault(match[1], []).append(directory)
52
+ if not retired:
53
+ return
54
+ for row in self.connection.execute("SELECT source_id FROM sources").fetchall():
55
+ source_id = row[0]
56
+ try:
57
+ canonical = self._snapshot_directory(source_id)
58
+ except ValueError:
59
+ continue
60
+ candidates = retired.get(canonical.name, [])
61
+ if not candidates:
62
+ continue
63
+ references = list(self.connection.execute(
64
+ "SELECT version_id,snapshot_path FROM source_snapshots WHERE source_id=?", (source_id,)))
65
+ try:
66
+ current = self._record(source_id)
67
+ except (ValueError, TypeError):
68
+ current = None
69
+ if isinstance(current, dict):
70
+ references.append((current.get("version_id"), current.get("snapshot_path")))
71
+ for version_id, snapshot_path in references:
72
+ if not isinstance(version_id, str) or not re.fullmatch(r"v-[0-9a-f]{64}", version_id):
73
+ continue
74
+ destination = canonical / version_id
75
+ if (not isinstance(snapshot_path, str) or Path(snapshot_path) != destination / "package"
76
+ or destination.exists() or destination.is_symlink()):
77
+ continue
78
+ for directory in candidates:
79
+ saved = directory / version_id
80
+ if saved.is_symlink() or not saved.is_dir():
81
+ continue
82
+ try:
83
+ if any(entry.is_symlink() for entry in saved.rglob("*")):
84
+ continue
85
+ manifest = self._imported_snapshot(read_input(saved / "package"))
86
+ except (OSError, ValueError, TypeError, KeyError):
87
+ continue
88
+ if not manifest or manifest.get("source_id") != source_id or manifest.get("version_id") != version_id:
89
+ continue
90
+ canonical.mkdir(exist_ok=True)
91
+ saved.rename(destination)
92
+ break
93
+ # Unreferenced versions and empty retirement directories stay untouched.
94
+ # A committed removal must never be undone just because its cleanup died.
39
95
 
40
96
  def _now(self):
41
97
  return datetime.fromtimestamp(self.clock(), timezone.utc).isoformat()
@@ -296,36 +352,66 @@ class SourceManager:
296
352
  raise ValueError("Managed snapshot directory is not safe to change")
297
353
  return directory
298
354
 
355
+ @contextmanager
356
+ def _source_removal(self, source_id):
357
+ """Retire snapshots before commit; clean only this removal's directory."""
358
+ if self.connection.in_transaction:
359
+ raise ValueError("Source remove/merge must run outside another index transaction")
360
+ snapshot = retired = None
361
+ cleanup = {"snapshot_removed": False}
362
+ try:
363
+ with self.index.transaction():
364
+ yield cleanup
365
+ snapshot = self._snapshot_directory(source_id)
366
+ if snapshot is not None and snapshot.is_dir():
367
+ retired = snapshot.with_name(".removed-" + snapshot.name + "-" + uuid.uuid4().hex)
368
+ snapshot.rename(retired)
369
+ except BaseException:
370
+ if retired is not None and retired.is_dir():
371
+ # A failed commit may have released SQLite's lock. Reacquire it
372
+ # and preserve any new snapshot published since that rollback.
373
+ with self.index.transaction():
374
+ self._snapshot_directory(source_id)
375
+ if not snapshot.exists():
376
+ retired.rename(snapshot)
377
+ else:
378
+ # Version directories are immutable. Restore missing old
379
+ # versions without replacing a concurrent import's files.
380
+ for version in retired.iterdir():
381
+ destination = snapshot / version.name
382
+ if not destination.exists():
383
+ version.rename(destination)
384
+ shutil.rmtree(retired)
385
+ raise
386
+ if retired is not None:
387
+ shutil.rmtree(retired)
388
+ cleanup["snapshot_removed"] = True
389
+
299
390
  def remove(self, source_id, confirm=False):
300
391
  """Delete a source, its indexed rows and its managed snapshots.
301
392
 
302
393
  Research inventories that froze this source keep their saved input but
303
394
  can no longer resolve it, so a caller must confirm explicitly.
304
395
  """
305
- record = self._record(source_id)
306
- if record is None:
307
- raise ValueError("Unknown source_id: " + str(source_id))
308
- counts = self._source_counts(source_id)
309
- snapshot = self._snapshot_directory(source_id)
310
- aliases = [row[0] for row in self.connection.execute(
311
- "SELECT input_path FROM source_aliases WHERE source_id=? ORDER BY input_path", (source_id,))]
312
- if not confirm:
313
- return {"source_id": source_id, "status": "confirmation_required", "would_remove": counts,
314
- "aliases": aliases, "snapshot_directory": str(snapshot) if snapshot else None,
315
- "impact": "Research tasks that froze this source keep their saved inventory but stop resolving "
316
- "its items; Wiki pages citing those claims become stale. Re-run with confirm=true "
317
- "(CLI: --confirm) to delete."}
318
- with self.index.transaction():
396
+ transaction = self._source_removal(source_id) if confirm else self.index.read_snapshot()
397
+ with transaction as cleanup:
398
+ self._record(source_id)
399
+ counts = self._source_counts(source_id)
400
+ snapshot = self._snapshot_directory(source_id)
401
+ aliases = [row[0] for row in self.connection.execute(
402
+ "SELECT input_path FROM source_aliases WHERE source_id=? ORDER BY input_path", (source_id,))]
403
+ if not confirm:
404
+ return {"source_id": source_id, "status": "confirmation_required", "would_remove": counts,
405
+ "aliases": aliases, "snapshot_directory": str(snapshot) if snapshot else None,
406
+ "impact": "Research tasks that froze this source keep their saved inventory but stop resolving "
407
+ "its items; Wiki pages citing those claims require review. Re-run with confirm=true "
408
+ "(CLI: --confirm) to delete."}
319
409
  for table in ("items", "sections", "files", "nodes", "edges", "memberships",
320
410
  "source_snapshots", "source_aliases", "source_records"):
321
411
  self.connection.execute("DELETE FROM %s WHERE source_id=?" % table, (source_id,))
322
412
  self.connection.execute("DELETE FROM sources WHERE source_id=?", (source_id,))
323
- removed_snapshot = False
324
- if snapshot is not None and snapshot.is_dir() and not snapshot.is_symlink():
325
- shutil.rmtree(snapshot)
326
- removed_snapshot = True
327
413
  return {"source_id": source_id, "status": "removed", "removed": counts,
328
- "aliases": aliases, "snapshot_removed": removed_snapshot}
414
+ "aliases": aliases, **cleanup}
329
415
 
330
416
  def merge(self, source_id, into_source_id, confirm=False):
331
417
  """Drop a duplicate source whose content version equals the target's.
@@ -335,23 +421,23 @@ class SourceManager:
335
421
  """
336
422
  if source_id == into_source_id:
337
423
  raise ValueError("source_id and into_source_id must differ")
338
- record, target = self._record(source_id), self._record(into_source_id)
339
- if record is None:
340
- raise ValueError("Unknown source_id: " + str(source_id))
341
- if target is None:
342
- raise ValueError("Unknown into_source_id: " + str(into_source_id))
343
- if record.get("version_id") != target.get("version_id"):
344
- raise ValueError("Sources hold different content versions; keep one explicitly with source remove "
345
- "instead of merging")
346
- aliases = [row[0] for row in self.connection.execute(
347
- "SELECT input_path FROM source_aliases WHERE source_id=? ORDER BY input_path", (source_id,))]
348
- if not confirm:
349
- return {"source_id": source_id, "into_source_id": into_source_id, "status": "confirmation_required",
350
- "version_id": record.get("version_id"), "moved_aliases": aliases,
351
- "impact": "The duplicate is deleted and its paths re-point at into_source_id; saved research "
352
- "inventories that froze the duplicate stop resolving. Re-run with confirm=true "
353
- "(CLI: --confirm) to merge."}
354
- with self.index.transaction():
424
+ transaction = self._source_removal(source_id) if confirm else self.index.read_snapshot()
425
+ with transaction as cleanup:
426
+ record, target = self._record(source_id), self._record(into_source_id)
427
+ if any(not isinstance(row.get("version_id"), str)
428
+ or not re.fullmatch(r"v-[0-9a-f]{64}", row["version_id"]) for row in (record, target)):
429
+ raise ValueError("Sources need synchronized content versions before merging; sync legacy sources first")
430
+ if record["version_id"] != target["version_id"]:
431
+ raise ValueError("Sources hold different content versions; keep one explicitly with source remove "
432
+ "instead of merging")
433
+ aliases = [row[0] for row in self.connection.execute(
434
+ "SELECT input_path FROM source_aliases WHERE source_id=? ORDER BY input_path", (source_id,))]
435
+ if not confirm:
436
+ return {"source_id": source_id, "into_source_id": into_source_id, "status": "confirmation_required",
437
+ "version_id": record["version_id"], "moved_aliases": aliases,
438
+ "impact": "The duplicate is deleted and its paths re-point at into_source_id; saved research "
439
+ "inventories that froze the duplicate stop resolving. Re-run with confirm=true "
440
+ "(CLI: --confirm) to merge."}
355
441
  for alias in aliases:
356
442
  self.connection.execute("INSERT OR IGNORE INTO source_aliases VALUES(?,?)", (into_source_id, alias))
357
443
  self.connection.execute("INSERT OR IGNORE INTO source_aliases VALUES(?,?)",
@@ -360,14 +446,9 @@ class SourceManager:
360
446
  "source_snapshots", "source_aliases", "source_records"):
361
447
  self.connection.execute("DELETE FROM %s WHERE source_id=?" % table, (source_id,))
362
448
  self.connection.execute("DELETE FROM sources WHERE source_id=?", (source_id,))
363
- snapshot = self._snapshot_directory(source_id)
364
- removed_snapshot = False
365
- if snapshot is not None and snapshot.is_dir() and not snapshot.is_symlink():
366
- shutil.rmtree(snapshot)
367
- removed_snapshot = True
368
449
  return {"source_id": source_id, "into_source_id": into_source_id, "status": "merged",
369
450
  "version_id": record.get("version_id"), "moved_aliases": aliases,
370
- "snapshot_removed": removed_snapshot}
451
+ **cleanup}
371
452
 
372
453
  def _observation(self, source_id, record, state, **fields):
373
454
  record.update(state=state, checked_at=self._now(), **fields)
package/src/wiki.py CHANGED
@@ -206,7 +206,11 @@ class WikiStore:
206
206
  if key not in cache:
207
207
  cache[key] = self.research.source_freshness(state=cache[research_id][1])
208
208
  freshness = cache[key]
209
- if freshness["requires_review"]:
209
+ if freshness.get("state") == "unknown":
210
+ issues.append({"severity": "warning", "code": "source_input_unavailable", "research_id": research_id,
211
+ "message": "The cited bookmark input cannot be checked; restore or synchronize its source before reviewing this page.",
212
+ "source_freshness": freshness})
213
+ elif freshness["requires_review"]:
210
214
  # A recorded review of this exact input version closes the
211
215
  # warning. A later change produces a new version and reopens it.
212
216
  acknowledged = next((row for row in self._acknowledged(record)
@@ -312,6 +316,11 @@ class WikiStore:
312
316
  if expected_revision != (previous["revision"] if previous else 0):
313
317
  raise ValueError("Wiki revision changed; read the current page before updating")
314
318
  prior_record = self._revision(previous) if previous else None
319
+ if acknowledge_all and prior_record is not None:
320
+ # A freshness acknowledgement must preserve the previously
321
+ # published evidence baseline, even if it changed after get().
322
+ if any(issue["severity"] == "error" for issue in self._issues(prior_record, index, cache)):
323
+ raise ValueError("Wiki acknowledgement cannot accept invalid evidence or links; repair them before reviewing input changes")
315
324
  acknowledged = self._acknowledgements(references, cache, reviewed_input_version, acknowledge_all,
316
325
  page["review"]["note"], prior_record)
317
326
  if prior_record is None and len(index["pages"]) >= 5000:
@@ -361,6 +370,7 @@ class WikiStore:
361
370
  note = self._text(note, "Acknowledge note")
362
371
  current = self.get(page_id)
363
372
  revision = current["revision"] if expected_revision is None else expected_revision
373
+ current["page"]["review"]["note"] = note
364
374
  result = self.write(page_id, current["page"], "Reviewed against the current input: " + note,
365
375
  expected_revision=revision, acknowledge_all=True)
366
376
  result["acknowledged_note"] = note
@@ -424,7 +434,8 @@ class WikiStore:
424
434
  @staticmethod
425
435
  def _validation(issues):
426
436
  return {"status": "stale" if any(item["severity"] == "error" for item in issues) else
427
- "needs_review" if any(item["code"] == "source_input_changed" for item in issues) else "current",
437
+ "needs_review" if any(item["code"] in ("source_input_changed", "source_input_unavailable")
438
+ for item in issues) else "current",
428
439
  "issues": issues, "semantic_review": "caller_declared; not evaluated by lint"}
429
440
 
430
441
  def get(self, page_id, revision=None):