feat: simplify wikilink semantics and support line anchors (#412)

* feat: simplify local links and support line anchors

* fix: align line anchor tests with CI lint

* fix: preserve local links across file moves

* fix: encode markdown paths when rewriting links

* refactor(read): keep explicit line range parameters

* fix: simplify legacy link predicate compatibility

* docs: align local link behavior with implementation

* fix: skip unsupported markdown destination escapes

* fix: normalize workspace link paths across platforms

* fix: bound markdown link scanning

* fix: keep local link processing linear

* docs: clarify permissive markdown link parsing

* fix: handle local link processing failures

* refactor: limit file links to wikilink syntax

* docs: align wikilink contract with implementation

* fix: normalize dream and neighbor paths on Windows

* fix: resolve workspace path for neighbor expansion
This commit is contained in:
jinliyl 2026-08-05 11:47:50 +08:00 committed by GitHub
parent 6b035c6553
commit a9ec334adc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 587 additions and 491 deletions

View file

@ -32,8 +32,15 @@ jobs:
- name: Install package
run: |
python -m pip install --upgrade pip setuptools wheel
pip install agentscope
pip install -e .
pip install -e ".[dev,core]"
- name: Run version job
run: reme start service.backend=cli job=version
- name: Run Windows path tests
run: |
python -m pytest `
tests/unit/test_auto_dream.py::test_scan_day_files_includes_nested_md_and_excludes_interests `
tests/unit/test_auto_dream.py::test_dream_extract_matches_posix_catalog_paths `
tests/unit/test_read_with_neighbors.py::test_read_with_neighbors_uses_posix_nested_path `
-v

View file

@ -9,7 +9,7 @@ units into `digest/`, and generates the day's `interests.yaml` for proactive use
</p>
Its daily inputs usually come from [Auto Memory](./auto_memory.md) and [Auto Resource](./auto_resource.md). For the file
semantics of `digest/`, `derived_from::`, and wikilinks, see [Memory as File](./memory_as_file.md). For the linking strategy
semantics of `digest/`, Sources sections, and wikilinks, see [Memory as File](./memory_as_file.md). For the linking strategy
used during Integrate, see [Auto Link](./auto_link.md). To read `interests.yaml`, use [Proactive](./proactive.md).
## Configuration
@ -198,8 +198,8 @@ jobs:
`auto_dream` consumes only daily inputs and does not rewrite daily bodies. Daily preserves facts and the original situation;
digest is the abstracted long-term memory layer.
`digest` is not a copy of the source text. Its body should preserve reusable abstractions, while details point back to sources
through `derived_from:: [[daily/<date>/...]]`. Links follow the workspace-relative wikilink semantics described in
`digest` is not a copy of the source text. Its body should preserve reusable abstractions, while a Sources section points
back with entries such as `- [[daily/<date>/...]]`. Links follow the workspace-relative wikilink semantics described in
[Memory as File](./memory_as_file.md).
`auto_dream` does not invent an overview from nothing. Only content that actually appears in daily input and is extracted as

View file

@ -32,7 +32,7 @@ link to multiple sources and multiple related digest nodes.
|---|---|
| The same memory already exists | Recall and update the existing node instead of creating a duplicate. |
| New and existing material are related | Write workspace-relative wikilinks into the body. |
| A digest node is disconnected from its sources | Point back to daily/resource source material with `derived_from:: [[...]]`. |
| A digest node is disconnected from its sources | Add daily/resource links under a `## Sources` section. |
| A node contains only isolated prose | Add links to related digest nodes on both CREATE and UPDATE. |
## Toolchain
@ -77,20 +77,22 @@ Every unit must select one action:
| Action | Linking semantics |
|---|---|
| `CREATE` | Write a new `digest/<bucket>/<slug>.md` and add source and related-node links to its body. |
| `CORROBORATE` | The same abstraction appeared again; append a new `derived_from:: [[...]]` and strengthen the description when needed. |
| `CORROBORATE` | The same abstraction appeared again; append its source link and strengthen the description when needed. |
| `REFINE` | New material extends the existing node; insert the additional content in the appropriate section and preserve existing links. |
| `CORRECT` | New material corrects the existing node; use source links to identify the basis for the correction. |
An UPDATE should be additive whenever possible: do not delete existing wikilinks or `derived_from` entries. This prevents
An UPDATE should be additive whenever possible: do not delete existing wikilinks or source entries. This prevents
later graph indexing and retrieval from losing edges.
### 3. Write source edges
Source edges use Markdown wikilinks:
Source edges are ordinary wikilinks grouped under a Markdown heading:
```markdown
derived_from:: [[daily/2026-06-20/session.md]]
derived_from:: [[resource/2026-06-20/paper.md]]
## Sources
- [[daily/2026-06-20/session.md]]
- [[resource/2026-06-20/paper.md]]
```
These edges represent the evidence behind a digest node. Plain-text descriptions do not count as source edges because only
@ -99,17 +101,14 @@ wikilinks can be parsed reliably by the file graph. For the complete parsing rul
### 4. Write relationships between digest nodes
Relationships between digest nodes also use complete workspace-relative paths:
Relationships between digest nodes use complete workspace-relative paths woven into natural prose:
```markdown
relates_to:: [[digest/wiki/hybrid-search.md]]
depends_on:: [[digest/procedure/rebuild-index.md]]
blocks_on:: [[digest/personal/team-review-preference.md]]
This design extends [[digest/wiki/hybrid-search.md]] and uses
[[digest/procedure/rebuild-index.md]]. Follow
[[digest/personal/team-review-preference.md]] during review.
```
Predicates are open-ended. Common forms include `relates_to::`, `depends_on::`, and `blocks_on::`. The predicate sits outside
the brackets, while the target path goes inside `[[...]]` and should include the `.md` suffix.
## Bucket Differences
`auto_link` adjusts the shape of its output according to the unit bucket:

View file

@ -23,7 +23,7 @@ ReMe represents memory as files not merely for convenient storage, but to give l
|---|---|
| Readable | Users can open the workspace directly and read daily notes, digest nodes, and source material like ordinary notes. |
| Editable | Users and agents can correct, extend, move, or delete memory with file operations, without a specialized database client. |
| Traceable | Long-term conclusions in digest can point back to daily, resource, or session sources through `derived_from:: [[...]]`. |
| Traceable | Long-term conclusions in digest can point back to daily, resource, or session files from a Sources section. |
| Portable | The workspace is an ordinary directory. Markdown, JSONL, YAML, and resource files can be backed up, synchronized, versioned, or moved to other tools. |
| Indexable | Although the files are plain text, ReMe parses frontmatter, chunks, and wikilinks to build a retrieval index and file graph. |
| Collaborative | Humans judge and correct; agents organize, link, and retrieve. Both operate on the same files. |
@ -131,9 +131,7 @@ tags: [new energy, solar]
# Conclusions
The solar supply chain consists of [[digest/wiki/polysilicon.md]], wafers, cells, and modules.
upstream:: [[digest/wiki/polysilicon.md]]
[company:: [[digest/wiki/longi.md|LONGi]]]
One major producer is [[digest/wiki/longi.md|LONGi]].
```
### Frontmatter
@ -163,15 +161,18 @@ confidence: observed
The user repeatedly asks documentation to explain motivation, boundaries, and examples while avoiding marketing language.
derived_from:: [[daily/2026-06-20/session-a.md]]
related:: [[digest/procedure/technical-documentation.md]]
Apply this preference when following [[digest/procedure/technical-documentation.md]].
## Sources
- [[daily/2026-06-20/session-a.md]]
```
This has three benefits:
1. `name` and `description` serve as lightweight summaries in lists, recall results, and agent decisions.
2. The body can carry fuller facts, conditions, counterexamples, and sources.
3. Typed wikilinks such as `derived_from::` and `related::` can be parsed by the graph and maintained when files move.
3. Ordinary wikilinks can be parsed by the graph and maintained when files move.
Frontmatter is best for stable, short, structured fields; the body is best for explanations meant for people. Do not put long
body text into YAML fields.
@ -181,10 +182,10 @@ body text into YAML fields.
Wikilinks express relationships between files with `[[...]]`:
```text
[[digest/wiki/solar.md]]
[[digest/wiki/solar.md#supply-chain]]
[[digest/wiki/solar.md|solar]]
![[resource/2026-06-01/report.md]]
[[daily/2026-06-20/session.md]]
[[notes/example.md#L9]]
[[notes/example.md#L9-L10]]
[[notes/example.md#L9-L10,L15-L20]]
```
ReMe wikilinks use **literal path semantics**:
@ -196,54 +197,59 @@ ReMe wikilinks use **literal path semantics**:
ReMe does not append `.md` automatically, search by filename, or automatically resolve folder notes. Use complete
workspace-relative paths with their extensions.
Ordinary Markdown links such as `[label](../wiki/example.md)` do not create `FileLink` edges and are not rewritten by move or
retarget operations.
Anchors such as `#L9`, `#L9-L10`, and `#L9-L10,L15-L20` remain ordinary `target_anchor` strings in the graph. The graph
parser does not validate line-anchor syntax, so values such as `#L0`, `#L10-L9`, and `#L9,` are also stored. The `read` job
does not interpret an anchor appended to `path`; use the separate 1-based, inclusive `start_line` and `end_line` arguments to
read a range, for example `read(path="digest/wiki/solar.md", start_line=9, end_line=10)`.
Wikilinks support these behaviors:
```text
body link -> create a FileLink
predicate:: link -> create a FileLink with a relationship name
move a file -> rewrite [[old path]] in inbound edges by default
delete a file -> return remaining inbound edges so references can be cleaned up
search match -> expand inbound and outbound links to provide context
```
Supported relationship forms:
```markdown
industry:: [[digest/wiki/new-energy.md]]
[competitor:: [[digest/wiki/byd.md]]]
```
Parsed result:
```text
FileLink
source_path = current file
target_path = digest/wiki/new-energy.md
predicate = industry
target_path = notes/example.md
target_anchor = L9-L10,L15-L20
```
Older documents containing wrappers such as `related:: [[path]]`,
`- related:: [[path]]`, or `[related:: [[path]]]` remain readable. ReMe
ignores the surrounding text and indexes the inner `[[path]]` as an ordinary
link. After upgrading from a version that stored typed links, run `reme reindex`
once to rebuild the derived graph without the removed relationship field.
### Sources and Relationships
The two most important link types in ReMe are source links and conceptual relationship links.
A source link explains where a long-term memory came from:
A Sources section records where a long-term memory came from:
```markdown
derived_from:: [[daily/2026-06-20/session-a.md]]
derived_from:: [[resource/2026-06-20/report.pdf]]
## Sources
- [[daily/2026-06-20/session-a.md]]
- [[resource/2026-06-20/report.pdf]]
```
A conceptual relationship link explains which other long-term memories relate to the node:
A conceptual relationship link explains which other long-term memories relate to the node. Weave it into natural prose:
```markdown
related:: [[digest/wiki/solar-supply-chain.md]]
depends_on:: [[digest/procedure/research-report-analysis.md]]
contrasts_with:: [[digest/wiki/central-inverter.md]]
This analysis extends [[digest/wiki/solar-supply-chain.md]], follows
[[digest/procedure/research-report-analysis.md]], and contrasts with
[[digest/wiki/central-inverter.md]].
```
Ordinary body wikilinks also create graph edges, but when the relationship itself has semantic value, prefer
`predicate:: [[path]]`. This makes the meaning of links clearer to search, graph traversal, and later agent integration.
## Human and Agent Editing
Because memory is stored as files, users can edit the workspace directly, while agents can read and write the same files
@ -257,8 +263,8 @@ through ReMe's file tools. Both follow the same conventions:
| Delete a file | Check inbound links first. ReMe's delete tool returns source files that still point to the target, making dangling references easier to clean up. |
| Edit metadata | Use frontmatter for short fields. When the body changes substantially, update `description` as well. |
A practical rule is: **an agent may rewrite the wording, but it must not lose evidence edges**. In particular,
`derived_from:: [[...]]` and existing digest-to-digest wikilinks are the basis for traceable and extensible long-term memory.
A practical rule is: **an agent may rewrite the wording, but it must not lose evidence edges**. In particular, Sources entries
and existing digest-to-digest wikilinks are the basis for traceable and extensible long-term memory.
## Path Semantics

View file

@ -66,7 +66,7 @@ stable batch of changes.
4. For a deleted file, remove its records from `file_store`, `keyword_index`, and `file_graph`.
5. When changes exist, dump state to `metadata/` so it can be restored on the next startup.
The Markdown chunker parses YAML frontmatter, heading structure, and `[[...]]` into `FileNode`, `FileChunk`, and `FileLink`
The Markdown chunker parses YAML frontmatter, heading structure, and wikilinks into `FileNode`, `FileChunk`, and `FileLink`
objects. For detailed chunking rules, see [Memory as File](./memory_as_file.md#memory-chunking).
### Index Optimization
@ -182,7 +182,8 @@ and then adds the new chunk text. Deletion is lazy; the index can later be compa
"Progressive" in Memory Search does not mean putting the entire repository into one result. Retrieval expands in three layers:
1. Chunk recall: return only the `limit` most relevant text fragments.
2. File location: each result includes `path:start_line-end_line`, allowing the caller to read the source precisely with `read`.
2. File location: each result includes `path:start_line-end_line`. Pass the path and line bounds separately as `path`,
`start_line`, and `end_line` when calling `read`; the range is not part of the `path` value.
3. Link neighbors: call `expand_links()` for each matched file and expand at most `max_links_per_direction` outlinks and
inlinks.
@ -194,7 +195,7 @@ matched chunk
-> file_store.get_outlinks(path)
-> file_store.get_inlinks(path)
-> file_store.get_nodes(neighbor_paths)
-> render neighbor path, name, description, predicate, and anchor
-> render neighbor path, name, description, and anchor
```
This keeps search results short while still showing which long-term nodes, resources, or other daily notes a memory connects
@ -216,10 +217,8 @@ Typical text structure:
...matched memory fragment...
outlinks (2):
-> digest/indexing.md name="Indexing" description="..."
via predicate=related
inlinks (1):
<- daily/2026-06-19.md name="..."
via plain
```
`counts` reports how many vector and keyword candidates were recalled and how many results were ultimately returned. With

View file

@ -110,17 +110,19 @@ name: Cobalt
description: A key raw material for lithium-battery cathodes, with production concentrated in the DRC
---
downstream_product:: [[digest/wiki/ternary-cathodes.md]]
producer:: [[digest/wiki/glencore.md]]
source_event:: [[daily/2026-05-18/2026-05-18-close.md]]
# Cobalt
Used by [[digest/wiki/ternary-cathodes.md]]; a major producer is [[digest/wiki/glencore.md]].
## Supply
Glencore's third-quarter cobalt output fell 18% year over year. Continue monitoring how tighter supply affects prices.
## Policy risk
Changes to mining-rights policy in the DRC may affect KFM mine operations and should be tracked together with CMOC.
## Sources
- [[daily/2026-05-18/2026-05-18-close.md]]
```
Note that wikilinks use literal path semantics. Prefer complete workspace-relative paths with the `.md` extension. ReMe
@ -185,10 +187,10 @@ The result shape is:
Glencore's third-quarter cobalt output fell 18% year over year...
outlinks:
-> digest/wiki/ternary-cathodes.md name="Ternary Cathodes" via predicate=downstream_product
-> digest/wiki/glencore.md name="Glencore" via predicate=producer
-> digest/wiki/ternary-cathodes.md name="Ternary Cathodes"
-> digest/wiki/glencore.md name="Glencore"
inlinks:
<- digest/wiki/ternary-cathodes.md name="Ternary Cathodes" via predicate=upstream_material
<- digest/wiki/ternary-cathodes.md name="Ternary Cathodes"
========== digest/wiki/ternary-cathodes.md:5-18 [score=0.0139 keyword=3.2017] ==========
...
@ -301,11 +303,10 @@ name: TypeScript project build OOM diagnostic path
description: When a build stalls and memory grows, check the type-checking process first
---
source_event:: [[daily/2026-03-10/build-oom-2026-03-10.md]]
related_preference:: [[digest/personal/code-style.md]]
# TypeScript Project Build OOM Diagnostic Path
Apply [[digest/personal/code-style.md]] while following this runbook.
## Symptoms
The build stalls near the end. CPU usage is low, but memory keeps growing.
@ -317,6 +318,10 @@ The build stalls near the end. CPU usage is low, but memory keeps growing.
## Known ineffective paths
- Deleting `.cache` alone did not resolve the issue on 2026-03-10.
- Upgrading the terser plugin did not resolve the issue on 2026-03-10.
## Sources
- [[daily/2026-03-10/build-oom-2026-03-10.md]]
```
Example `digest/personal/code-style.md`:
@ -411,13 +416,14 @@ name: Alice
description: A friend of the user who often recommends reading material
---
recommended_book:: [[digest/wiki/deep-work.md]]
source_event:: [[daily/2026-04-20/lunch-with-alice.md]]
# Alice
## Reading recommendations
At lunch on 2026-04-20, Alice recommended a book about attention and deep work.
At lunch on 2026-04-20, Alice recommended [[digest/wiki/deep-work.md]], a book about attention and deep work.
## Sources
- [[daily/2026-04-20/lunch-with-alice.md]]
```
### An associative recall
@ -439,7 +445,7 @@ Matches:
```text
digest/personal/alice.md
outlinks:
-> digest/wiki/deep-work.md via predicate=recommended_book
-> digest/wiki/deep-work.md
daily/2026-04-20/lunch-with-alice.md
```

View file

@ -7,7 +7,7 @@
<img src="../figure/auto-dream-and-proactive.svg" alt="ReMe Auto Dream and Proactive 从 daily 到 digest 再到 proactive 的流程" width="92%">
</p>
它消费的 daily 输入通常来自 [Auto Memory](./auto_memory.md) 和 [Auto Resource](./auto_resource.md)。`digest/``derived_from::`
它消费的 daily 输入通常来自 [Auto Memory](./auto_memory.md) 和 [Auto Resource](./auto_resource.md)。`digest/`Sources 章节
和 wikilink 的文件语义见 [Memory as File](./memory_as_file.md)Integrate 阶段的链接策略详见 [Auto Link](./auto_link.md)。
`interests.yaml` 的读取接口见 [Proactive](./proactive.md)。
@ -189,7 +189,7 @@ jobs:
`auto_dream` 只消费 daily 输入,不改写 daily 正文。daily 是事实和现场记录digest 才是抽象后的长期记忆层。
`digest` 不是原文复制。正文应保留可复用抽象,细节通过 `derived_from:: [[daily/<date>/...]]` 指回来源。链接写法遵循
`digest` 不是原文复制。正文应保留可复用抽象,细节通过 Sources 章节中的 `- [[daily/<date>/...]]` 指回来源。链接写法遵循
[Memory as File](./memory_as_file.md) 中的 workspace-relative wikilink 语义。
`auto_dream` 不凭空生成总览。只有 daily 输入中确实出现、并被抽取为 unit 或 topic 的内容,才会进入 digest 或

View file

@ -30,7 +30,7 @@ Integrate 阶段对每个 unit 独立运行。一个 unit 只落到一个目标
|--------------|----------------------------------------------------|
| 已有相同记忆 | 召回后更新旧节点,而不是重复创建。 |
| 新旧材料有关联 | 在正文中写入 workspace-relative wikilink。 |
| digest 与来源断开 | `derived_from:: [[...]]` 指回 daily/resource 原始材料。 |
| digest 与来源断开 | `## Sources` 章节加入指向 daily/resource 的链接。 |
| 节点只有孤立正文 | 在 CREATE 和 UPDATE 时都补充相关 digest 节点链接。 |
## 工具链
@ -73,19 +73,21 @@ Agent 先用 unit 的触发条件、动词、名词、同义词和可能的 fail
| 动作 | 链接含义 |
|---------------|-----------------------------------------------------|
| `CREATE` | 写入新的 `digest/<bucket>/<slug>.md`,并在新正文里加入来源和相关节点链接。 |
| `CORROBORATE` | 同一抽象再次出现,追加新的 `derived_from:: [[...]]`,必要时强化描述。 |
| `CORROBORATE` | 同一抽象再次出现,追加来源链接,必要时强化描述。 |
| `REFINE` | 新材料扩展了旧节点,把补充内容插入合适段落,并保留旧链接。 |
| `CORRECT` | 新材料修正旧节点,用来源链接标出修正依据。 |
UPDATE 必须尽量只增不删:不要删除已有 wikilink 或 `derived_from`。这是为了让后续图谱索引和检索不会丢边。
UPDATE 必须尽量只增不删:不要删除已有 wikilink 或来源条目。这是为了让后续图谱索引和检索不会丢边。
### 3. 写来源边
来源边使用 markdown wikilink
来源边是归档在 Markdown 固定章节下的普通 Wikilink
```markdown
derived_from:: [[daily/2026-06-20/session.md]]
derived_from:: [[resource/2026-06-20/paper.md]]
## Sources
- [[daily/2026-06-20/session.md]]
- [[resource/2026-06-20/paper.md]]
```
这些边表示 digest 节点的证据来源。纯文本描述不算来源边,因为只有 wikilink 能被 file graph 稳定解析。更完整的 wikilink
@ -93,17 +95,14 @@ derived_from:: [[resource/2026-06-20/paper.md]]
### 4. 写 digest 关联边
digest 之间的关联使用完整 workspace-relative 路径:
digest 之间的关联使用完整 workspace-relative 路径,并自然织入正文
```markdown
relates_to:: [[digest/wiki/hybrid-search.md]]
depends_on:: [[digest/procedure/rebuild-index.md]]
blocks_on:: [[digest/personal/team-review-preference.md]]
这个设计扩展了 [[digest/wiki/hybrid-search.md]],并使用
[[digest/procedure/rebuild-index.md]]。评审时遵循
[[digest/personal/team-review-preference.md]]
```
谓词是开放的,常见写法包括 `relates_to::``depends_on::``blocks_on::`。谓词在括号外,目标路径在 `[[...]]` 内,并且应包含
`.md` 后缀。
## Bucket 差异
`auto_link` 的规则会随 unit bucket 调整写入形态:

View file

@ -22,7 +22,7 @@ ReMe 把记忆设计成文件,不只是为了“方便存储”,而是为了
|----------|----------------------------------------------------------------------|
| 可读 | 用户可以直接打开 workspace像读普通笔记一样读 daily、digest 和原始材料。 |
| 可编辑 | 用户和 Agent 都能用文件操作修正、补充、移动或删除记忆,不必依赖专用数据库客户端。 |
| 可追溯 | digest 中的长期结论可以通过 `derived_from:: [[...]]` 回到 daily、resource 或 session 原文。 |
| 可追溯 | digest 中的长期结论可以通过 Sources 章节回到 daily、resource 或 session 原文。 |
| 可迁移 | workspace 是普通目录Markdown、JSONL、YAML 和资源文件可以被备份、同步、版本管理或迁移到其他工具。 |
| 可索引 | 文件虽然是普通文本,但 ReMe 会解析 frontmatter、chunk、wikilink构建检索索引和文件图谱。 |
| 可协作 | 人负责判断和修正Agent 负责整理、链接和检索;二者看到和操作的是同一套文件。 |
@ -124,9 +124,7 @@ tags: [新能源, 光伏]
# 结论
光伏产业链可以拆成 [[digest/wiki/硅料.md]]、硅片、电池片和组件。
upstream:: [[digest/wiki/硅料.md]]
[company:: [[digest/wiki/隆基绿能.md|隆基]]]
主要生产商包括 [[digest/wiki/隆基绿能.md|隆基]]。
```
### Frontmatter
@ -156,15 +154,18 @@ confidence: observed
用户多次要求文档补充动机、边界和例子,但避免营销式表述。
derived_from:: [[daily/2026-06-20/session-a.md]]
related:: [[digest/procedure/技术文档写作.md]]
执行 [[digest/procedure/技术文档写作.md]] 时应用这个偏好。
## Sources
- [[daily/2026-06-20/session-a.md]]
```
这样做有三个好处:
1. `name``description` 可以在列表、召回结果和 Agent 判断中作为轻量摘要。
2. 正文可以承载更完整的事实、条件、反例和来源。
3. `derived_from::``related::` 这类 typed wikilink 可以被图谱解析,后续移动文件时也能被维护。
3. 普通 Wikilink 可以被图谱解析,后续移动文件时也能被维护。
Frontmatter 适合放稳定、短小、结构化的字段;正文适合放需要人读的解释。不要把大段正文塞进 YAML 字段。
@ -173,10 +174,10 @@ Frontmatter 适合放稳定、短小、结构化的字段;正文适合放需
Wikilink 用 `[[...]]` 表达文件之间的关系:
```text
[[digest/wiki/光伏.md]]
[[digest/wiki/光伏.md#产业链]]
[[digest/wiki/光伏.md|光伏]]
![[resource/2026-06-01/report.md]]
[[daily/2026-06-20/session.md]]
[[notes/example.md#L9]]
[[notes/example.md#L9-L10]]
[[notes/example.md#L9-L10,L15-L20]]
```
ReMe 的 wikilink 是**字面路径语义**
@ -187,54 +188,57 @@ ReMe 的 wikilink 是**字面路径语义**
它不会自动补 `.md`,不会按文件名搜索,也不会自动解析 folder note。推荐写完整的 workspace 相对路径,并带上扩展名。
`[label](../wiki/example.md)` 这类普通 Markdown 链接不会建立 `FileLink`move 或 retarget 操作也不会改写它们。
`#L9``#L9-L10``#L9-L10,L15-L20` 这类锚点会作为普通 `target_anchor` 字符串保存在图谱中。图谱解析器
不会校验行号锚点,因此 `#L0``#L10-L9``#L9,` 也会被保存。`read` 不会解析追加在 `path` 后的锚点;读取指定
范围时需要分别传入从 1 开始、首尾均包含的 `start_line``end_line`,例如
`read(path="digest/wiki/光伏.md", start_line=9, end_line=10)`
Wikilink 的作用:
```text
正文链接 -> 建立 FileLink
predicate:: 链接 -> 建立带关系名的 FileLink
move 文件 -> 默认改写入边中的 [[旧路径]]
delete 文件 -> 返回仍存在的入边,提示清理引用
search 命中 -> 可展开出入链,帮助理解上下文
```
支持的关系写法:
```markdown
industry:: [[digest/wiki/新能源.md]]
[competitor:: [[digest/wiki/比亚迪.md]]]
```
解析结果:
```text
FileLink
source_path = 当前文件
target_path = digest/wiki/新能源.md
predicate = industry
target_path = notes/example.md
target_anchor = L9-L10,L15-L20
```
旧文档中的 `related:: [[path]]``- related:: [[path]]`
`[related:: [[path]]]` 仍然可以读取。ReMe 会忽略外围文本,把内部 `[[path]]`
作为普通链接建立索引。从曾存储 typed link 的版本升级后,应执行一次 `reme reindex`
用源文件重建不含旧关系字段的派生图索引。
### 来源和关系
ReMe 里最重要的两类链接是来源链接和概念关系链接。
来源链接说明“这条长期记忆从哪里来”:
Sources 章节说明“这条长期记忆从哪里来”:
```markdown
derived_from:: [[daily/2026-06-20/session-a.md]]
derived_from:: [[resource/2026-06-20/report.pdf]]
## Sources
- [[daily/2026-06-20/session-a.md]]
- [[resource/2026-06-20/report.pdf]]
```
概念关系链接说明“这个节点和哪些长期记忆有关”:
概念关系链接说明“这个节点和哪些长期记忆有关”,并自然织入正文
```markdown
related:: [[digest/wiki/光伏产业链.md]]
depends_on:: [[digest/procedure/调研报告拆解流程.md]]
contrasts_with:: [[digest/wiki/集中式逆变器.md]]
这份分析扩展了 [[digest/wiki/光伏产业链.md]],遵循
[[digest/procedure/调研报告拆解流程.md]],并与
[[digest/wiki/集中式逆变器.md]] 对比。
```
普通正文 wikilink 也会建立图边,但当关系本身有语义价值时,推荐使用 `predicate:: [[path]]`。这能让搜索、图遍历和后续 Agent
整合更容易理解链接含义。
## 人工编辑和 Agent 编辑
因为记忆就是文件,用户可以直接在编辑器里改 workspaceAgent 也可以通过 ReMe 的文件工具读写同一批文件。两者遵守同一套约定:
@ -247,8 +251,8 @@ contrasts_with:: [[digest/wiki/集中式逆变器.md]]
| 删除文件 | 删除前检查入链ReMe 的 delete 会返回仍然指向目标的来源文件,方便清理悬空引用。 |
| 修改元数据 | 用 frontmatter 表达短字段;正文发生实质变化时同步更新 `description`。 |
一个实用规则是:**可以让 Agent 重写表达,但不要让它丢掉证据边**。尤其是 digest 节点中的 `derived_from:: [[...]]` 和已有
digest-to-digest wikilink是长期记忆可追溯和可扩展的基础。
一个实用规则是:**可以让 Agent 重写表达,但不要让它丢掉证据边**。尤其是 digest 节点中的 Sources 条目和已有
digest-to-digest Wikilink是长期记忆可追溯和可扩展的基础。
## 路径语义

View file

@ -62,7 +62,7 @@ index_update_loop:
4. 对删除的文件,从 `file_store``keyword_index``file_graph` 清掉对应记录。
5. 有变化时 dump 到 `metadata/`,让下次启动可以恢复。
Markdown chunker 会解析 YAML frontmatter、标题结构和 `[[...]]`,产出 `FileNode``FileChunk``FileLink`。更细的分块规则见
Markdown chunker 会解析 YAML frontmatter、标题结构和 wikilink,产出 `FileNode``FileChunk``FileLink`。更细的分块规则见
[Memory as File](./memory_as_file.md#memory-chunking)。
### 索引优化
@ -172,7 +172,8 @@ delete后续可通过 optimize 压缩索引。
Memory Search 的“渐进式”不是一次把全库内容塞进结果,而是分三层展开:
1. 第一层是 chunk 召回:只返回最相关的 `limit` 个文本片段。
2. 第二层是文件定位:每个结果带 `path:start_line-end_line`,可以继续用 `read` 精读原文件。
2. 第二层是文件定位:每个结果带 `path:start_line-end_line`。调用 `read` 时需要把它们分别作为 `path`
`start_line``end_line` 传入,行号范围不是 `path` 的一部分。
3. 第三层是链接邻居:对命中文件调用 `expand_links()`,展开最多 `max_links_per_direction` 个 outlinks 和 inlinks。
展开的数据来自 `file_graph`,不是重新扫文件:
@ -183,7 +184,7 @@ Memory Search 的“渐进式”不是一次把全库内容塞进结果,而是
-> file_store.get_outlinks(path)
-> file_store.get_inlinks(path)
-> file_store.get_nodes(neighbor_paths)
-> 渲染邻居的 path、name、description、predicate、anchor
-> 渲染邻居的 path、name、description、anchor
```
这让搜索结果既保持短,又能看到“这条记忆连接到哪些长期节点、资源或其他 daily note”。如果某条结果值得继续追可以用
@ -203,10 +204,8 @@ Memory Search 的“渐进式”不是一次把全库内容塞进结果,而是
...命中的记忆片段...
outlinks (2):
-> digest/indexing.md name="Indexing" description="..."
via predicate=related
inlinks (1):
<- daily/2026-06-19.md name="..."
via plain
```
`counts` 会告诉你本次向量、关键词各召回了多少候选,以及最终返回多少条。默认 embedding 关闭时,`vector` 通常是 `0``hybrid`

View file

@ -107,15 +107,17 @@ name: 钴
description: 锂电正极材料关键原料,主产区集中于刚果(金)
---
downstream_product:: [[digest/wiki/三元正极.md]]
producer:: [[digest/wiki/嘉能可.md]]
source_event:: [[daily/2026-05-18/2026-05-18-close.md]]
# 钴
用于 [[digest/wiki/三元正极.md]];主要生产商包括 [[digest/wiki/嘉能可.md]]。
## 供给端
嘉能可三季度钴产量同比下滑 18%,需要继续跟踪供给收缩对价格的影响。
## Sources
- [[daily/2026-05-18/2026-05-18-close.md]]
## 政策风险
刚果(金)矿权政策变化可能影响 KFM 矿运营,需联动跟踪洛阳钼业。
```
@ -179,10 +181,10 @@ reme search query="锂电 上下游 三元 正极 钴 宁德" limit=5
嘉能可三季度钴产量同比下滑 18%...
outlinks:
-> digest/wiki/三元正极.md name="三元正极" via predicate=downstream_product
-> digest/wiki/嘉能可.md name="嘉能可" via predicate=producer
-> digest/wiki/三元正极.md name="三元正极"
-> digest/wiki/嘉能可.md name="嘉能可"
inlinks:
<- digest/wiki/三元正极.md name="三元正极" via predicate=upstream_material
<- digest/wiki/三元正极.md name="三元正极"
========== digest/wiki/三元正极.md:5-18 [score=0.0139 keyword=3.2017] ==========
...
@ -291,11 +293,10 @@ name: TypeScript 项目构建 OOM 排查路径
description: build 卡住且内存上涨时,优先检查类型检查进程内存
---
source_event:: [[daily/2026-03-10/build-oom-2026-03-10.md]]
related_preference:: [[digest/personal/code-style.md]]
# TypeScript 项目构建 OOM 排查路径
执行这个 runbook 时遵循 [[digest/personal/code-style.md]]。
## 症状
构建卡在后段CPU 不高但内存持续上涨。
@ -307,6 +308,10 @@ related_preference:: [[digest/personal/code-style.md]]
## 已知无效路径
- 单纯删除 `.cache` 未解决 2026-03-10 的问题。
- 升级 terser 插件未解决 2026-03-10 的问题。
## Sources
- [[daily/2026-03-10/build-oom-2026-03-10.md]]
```
示例 `digest/personal/code-style.md`
@ -398,13 +403,14 @@ name: Alice
description: 用户朋友,常推荐阅读材料
---
recommended_book:: [[digest/wiki/deep-work.md]]
source_event:: [[daily/2026-04-20/lunch-with-alice.md]]
# Alice
## 阅读推荐
2026-04-20 午餐时推荐过一本关于注意力和深度工作的书。
2026-04-20 午餐时推荐过 [[digest/wiki/deep-work.md]],这是一本关于注意力和深度工作的书。
## Sources
- [[daily/2026-04-20/lunch-with-alice.md]]
```
### 一次联想式回忆
@ -426,7 +432,7 @@ reme search query="Alice 推荐 注意力 书 深度" limit=5
```text
digest/personal/alice.md
outlinks:
-> digest/wiki/deep-work.md via predicate=recommended_book
-> digest/wiki/deep-work.md
daily/2026-04-20/lunch-with-alice.md
```

View file

@ -42,12 +42,12 @@ class ComponentMixin:
return Path(self.app_context.app_config.workspace_dir).absolute()
def to_workspace_relative(self, path: str | Path) -> str:
"""Convert `path` to a workspace-relative string; return absolute path when outside."""
"""Convert ``path`` to a POSIX workspace path; keep outside paths absolute."""
abs_path = Path(path).absolute()
try:
return str(abs_path.relative_to(self.workspace_path))
return abs_path.relative_to(self.workspace_path).as_posix()
except ValueError:
return str(abs_path)
return abs_path.as_posix()
class Dependency:

View file

@ -73,7 +73,7 @@ class DefaultFileChunker(BaseFileChunker):
)
def _link_byte_spans(self, content: str) -> list[tuple[int, int]]:
"""Return [start, end) byte spans of every wikilink in content."""
"""Return byte spans of wikilinks."""
spans: list[tuple[int, int]] = []
last_char, last_byte = 0, 0
for wm in WikilinkHandler.iter_matches(content):

View file

@ -12,8 +12,7 @@ and merge adjacent small subtrees at their parent. Leaf blocks (table / code /
list / paragraph) split on internal boundaries and each piece is annotated
``[Part X/N]``. Wikilink extraction is
delegated to :class:`reme.utils.wikilink_handler.WikilinkHandler`
the single source of truth for ``[[...]]`` syntax (including
Dataview-style typed predicates).
the single source of truth for ``[[...]]`` syntax.
"""
import re

View file

@ -7,7 +7,7 @@ Property-graph mapping:
Virtual node: (:File {path}) placeholder created when something
links to a path that hasn't been upserted yet.
Edge: (:File)-[:LINKS {idx, anchor, predicate}]->(:File)
Edge: (:File)-[:LINKS {idx, anchor}]->(:File)
The ``links_json`` property doubles as the "is real" marker its
presence means the node was upserted with a payload; its absence
@ -43,7 +43,7 @@ from ...schema import FileLink, FileNode
from ...schema.file_node import FileFrontMatter
_TYPED_FRONTMATTER_FIELDS = {"name", "description"}
_LINK_FIELDS = {"source_path", "target_path", "target_anchor", "predicate"}
_LINK_FIELDS = {"source_path", "target_path", "target_anchor"}
# Properties that distinguish a "real" node from a virtual placeholder.
# Listed for the demote query (delete_nodes) so we can REMOVE them all.
@ -169,7 +169,6 @@ class Neo4jFileGraph(BaseFileGraph):
{
"idx": i,
"anchor": link.target_anchor,
"predicate": link.predicate,
"target": link.target_path,
}
for i, link in enumerate(node.links)
@ -211,7 +210,7 @@ class Neo4jFileGraph(BaseFileGraph):
UNWIND n.links AS link
MERGE (t:File {path: link.target})
MERGE (s)-[r:LINKS {idx: link.idx}]->(t)
SET r.anchor = link.anchor, r.predicate = link.predicate
SET r.anchor = link.anchor
""",
items=payload,
)
@ -313,7 +312,6 @@ class Neo4jFileGraph(BaseFileGraph):
{
"idx": i,
"anchor": link.get("target_anchor"),
"predicate": link.get("predicate"),
"target": link.get("target_path"),
}
for i, link in enumerate(links)
@ -340,7 +338,7 @@ class Neo4jFileGraph(BaseFileGraph):
UNWIND n.links AS link
MERGE (t:File {path: link.target})
MERGE (s)-[r:LINKS {idx: link.idx}]->(t)
SET r.anchor = link.anchor, r.predicate = link.predicate
SET r.anchor = link.anchor
""",
items=payload,
)
@ -369,8 +367,7 @@ class Neo4jFileGraph(BaseFileGraph):
WHERE s.links_json IS NOT NULL
MATCH (s)-[r:LINKS]->(t:File)
WHERE 1=1 {target_filter}
RETURN t.path AS target, r.anchor AS anchor,
r.predicate AS predicate, r.idx AS idx
RETURN t.path AS target, r.anchor AS anchor, r.idx AS idx
ORDER BY r.idx ASC
""",
path=path,
@ -381,7 +378,6 @@ class Neo4jFileGraph(BaseFileGraph):
source_path=path,
target_path=row["target"],
target_anchor=row.get("anchor"),
predicate=row.get("predicate"),
)
for row in rows
]
@ -405,8 +401,7 @@ class Neo4jFileGraph(BaseFileGraph):
WHERE 1=1 {target_filter}
MATCH (s:File)-[r:LINKS]->(t)
WHERE s.links_json IS NOT NULL
RETURN r.anchor AS anchor, r.predicate AS predicate,
r.idx AS idx, s.path AS source
RETURN r.anchor AS anchor, r.idx AS idx, s.path AS source
ORDER BY s.path ASC, r.idx ASC
""",
path=path,
@ -417,7 +412,6 @@ class Neo4jFileGraph(BaseFileGraph):
source_path=row["source"],
target_path=path,
target_anchor=row.get("anchor"),
predicate=row.get("predicate"),
)
for row in rows
]

View file

@ -7,12 +7,14 @@ class FileLink(BaseModel):
"""file link
[[target_path]]
[[target_path#target_anchor]]
predicate:: [[target_*]]
[predicate:: [[target_*]]]
"""
model_config = ConfigDict(extra="forbid")
source_path: str = Field(default=..., description="source file path relative to working dir")
target_path: str = Field(default=..., description="target file path relative to working dir")
target_anchor: str | None = Field(default=None, description="Heading or block anchor (text after '#')")
predicate: str | None = Field(default=None, description="Dataview-style typed-link predicate")
target_anchor: str | None = Field(default=None, description="Heading, block, or line anchor (text after '#')")
predicate: str | None = Field(
default=None,
exclude=True,
description="Deprecated compatibility field; accepted when loading legacy indexes but otherwise unused",
)

View file

@ -18,8 +18,8 @@ integrate_system_prompt_procedure: |
genuinely needs more.
- If the draft starts copying paragraphs or narrating the event, it belongs
in the source material, not digest.
- Provenance edges carry details: cite every relevant unit_paths entry with
`derived_from:: [[<path>]]`.
- The Sources section carries details: cite every relevant unit_paths entry
as `- [[<path>]]`.
- Digest-to-digest wikilinks carry the conceptual graph.
## Procedure Body Shape
@ -30,9 +30,9 @@ integrate_system_prompt_procedure: |
- Steps: numbered or terse bullets; each step is verb-led.
- Pre-conditions / inputs: short list, not prose.
- Failure modes / caveats: brief.
- `derived_from:: [[<material-path>]]`: at least one, and normally every
relevant path in unit_paths. Plain-prose provenance does NOT count; only
wikilinks survive future updates.
- `## Sources`: at least one `- [[<material-path>]]` item, and normally one
for every relevant path in unit_paths. Plain-prose provenance does NOT
count; only wikilinks survive future updates.
## Workflow
@ -50,7 +50,7 @@ integrate_system_prompt_procedure: |
- unrelated: ignore.
3. Choose exactly one action:
- CREATE: no same_abstraction hit; write `{digest_dir}/procedure/<slug>.md`.
- CORROBORATE: same procedure observed again; append derived_from and
- CORROBORATE: same procedure observed again; append its source link and
optionally strengthen wording.
- REFINE: new pre-condition, edge case, failure mode, scope, or step;
expand the relevant span or slot the step into the right position.
@ -58,21 +58,18 @@ integrate_system_prompt_procedure: |
tighten or annotate inline with `> note: contradicted by [[<path>]] - <one-line>`.
4. Weave related digest nodes into the body as wikilinks on both CREATE and
UPDATE. UPDATE must be additive: never remove existing wikilinks or
derived_from entries. Default to weaving more, not less; this is the only
source links. Default to weaving more, not less; this is the only
chance to attach recalled related nodes.
## Wikilink Graph
- Source provenance links point from digest back to material:
`derived_from:: [[daily/<date>/<session>.md]]` or
`derived_from:: [[resource/<path>]]`.
- A `## Sources` section points from digest back to material with bare list
items such as `- [[daily/<date>/<session>.md]]` and
`- [[resource/<path>]]`.
- Procedure nodes may link to any digest bucket:
`[[{digest_dir}/procedure/<slug>.md]]`,
`[[{digest_dir}/personal/<slug>.md]]`, or
`[[{digest_dir}/wiki/<slug>.md]]`.
- Useful predicates include `derived_from::`, `relates_to::`,
`depends_on::`, and `blocks_on::`; predicates are open and live outside
the brackets.
- Wikilinks must be full workspace-relative paths with `.md`.
- CREATE writes inside `{digest_dir}/procedure/`. UPDATE may target any
bucket if recall legitimately found the same abstraction.
@ -101,7 +98,7 @@ integrate_system_prompt_procedure_zh: |
- 正文短且抽象,通常 50-200 words只有流程本身确实需要时才更长。
- 如果草稿开始复制段落或叙述事件,说明细节放错层了。
- Provenance edge 承载细节:用 `derived_from:: [[<path>]]` 引用 unit_paths 中每个相关来源。
- Sources 章节承载细节:用 `- [[<path>]]` 引用 unit_paths 中每个相关来源。
- Digest 之间的 wikilink 承载概念图。
## Procedure 正文形态
@ -112,7 +109,7 @@ integrate_system_prompt_procedure_zh: |
- Steps编号或短 bullet每步以动词开头。
- Pre-conditions / inputs短列表不写长 prose。
- Failure modes / caveats简短。
- `derived_from:: [[<material-path>]]`:至少一条,通常覆盖 unit_paths 中每个相关 path。
- `## Sources`:至少一条 `- [[<material-path>]]`,通常覆盖 unit_paths 中每个相关 path。
纯文本 provenance 不算;只有 wikilink 会在未来更新中保留下来。
## 工作流
@ -126,22 +123,20 @@ integrate_system_prompt_procedure_zh: |
- unrelated忽略。
3. 选择且只选择一个 action
- CREATE没有 same_abstraction 命中;写入 `{digest_dir}/procedure/<slug>.md`。
- CORROBORATE同一流程再次出现追加 derived_from,可选强化措辞。
- CORROBORATE同一流程再次出现追加来源链接,可选强化措辞。
- REFINE新增前置条件、边界情况、失败模式、适用范围或步骤扩展相关段落或把步骤插到正确位置。
- CORRECT顺序错误、缺关键步骤、结果不好或有冲突收紧表述或用
`> note: contradicted by [[<path>]] - <one-line>` 内联标注。
4. CREATE 和 UPDATE 都要把 related digest 节点织入正文 wikilink。UPDATE 必须只增不删:不要删除已有
wikilink 或 derived_from。默认多织,而不是少织;这是挂接召回到的相关节点的唯一机会。
wikilink 或来源链接。默认多织,而不是少织;这是挂接召回到的相关节点的唯一机会。
## Wikilink 图
- 来源 provenance 从 digest 指回材料:
`derived_from:: [[daily/<date>/<session>.md]]` 或 `derived_from:: [[resource/<path>]]`。
- `## Sources` 章节用普通列表 Wikilink 从 digest 指回材料,例如
`- [[daily/<date>/<session>.md]]` 和 `- [[resource/<path>]]`。
- Procedure 节点可以链接任意 digest bucket
`[[{digest_dir}/procedure/<slug>.md]]`、`[[{digest_dir}/personal/<slug>.md]]`、
`[[{digest_dir}/wiki/<slug>.md]]`。
- 常用 predicate`derived_from::`、`relates_to::`、`depends_on::`、`blocks_on::`
predicate 词表开放,写在方括号外。
- Wikilink 必须是带 `.md` 的 workspace-relative path。
- CREATE 写入 `{digest_dir}/procedure/`。如果 recall 合法命中同一抽象UPDATE 可以跨 bucket。
- `edit` 只改正文,并尽量只增不删:不要删除 old span 中已有的 wikilink。
@ -171,8 +166,8 @@ integrate_system_prompt_personal: |
- Body is short and operational, usually 50-200 words.
- Do not narrate what the user said in detail; cite source material instead.
- Provenance edges carry details: cite every relevant unit_paths entry with
`derived_from:: [[<path>]]`.
- The Sources section carries details: cite every relevant unit_paths entry
as `- [[<path>]]`.
- Digest-to-digest wikilinks carry the conceptual graph.
## Personal Body Shape
@ -185,9 +180,9 @@ integrate_system_prompt_personal: |
- `How to apply:` contexts, tasks, boundaries, or exceptions.
- Do not invent exceptions or soften a hard preference unless the source
material explicitly supports that exception.
- `derived_from:: [[<material-path>]]`: at least one, and normally every
relevant path in unit_paths. Plain-prose provenance does NOT count; only
wikilinks survive future updates.
- `## Sources`: at least one `- [[<material-path>]]` item, and normally one
for every relevant path in unit_paths. Plain-prose provenance does NOT
count; only wikilinks survive future updates.
For preferences, prefer one node per preference rather than one large person
node; that is the granularity downstream search will hit.
@ -208,7 +203,7 @@ integrate_system_prompt_personal: |
- unrelated: ignore.
3. Choose exactly one action:
- CREATE: no same_abstraction hit; write `{digest_dir}/personal/<slug>.md`.
- CORROBORATE: the rule is reaffirmed; append derived_from and optionally
- CORROBORATE: the rule is reaffirmed; append its source link and optionally
strengthen confidence.
- REFINE: scope, condition, exception, or example changed; expand `How to apply:`.
- CORRECT: the user/team changed their mind or evidence conflicts; tighten
@ -216,21 +211,18 @@ integrate_system_prompt_personal: |
`> note: contradicted by [[<path>]] - <one-line>`.
4. Weave related digest nodes into the body as wikilinks on both CREATE and
UPDATE. UPDATE must be additive: never remove existing wikilinks or
derived_from entries. Default to weaving more, not less; this is the only
source links. Default to weaving more, not less; this is the only
chance to attach recalled related nodes.
## Wikilink Graph
- Source provenance links point from digest back to material:
`derived_from:: [[daily/<date>/<session>.md]]` or
`derived_from:: [[resource/<path>]]`.
- A `## Sources` section points from digest back to material with bare list
items such as `- [[daily/<date>/<session>.md]]` and
`- [[resource/<path>]]`.
- Personal nodes may link to any digest bucket:
`[[{digest_dir}/personal/<slug>.md]]`,
`[[{digest_dir}/procedure/<slug>.md]]`, or
`[[{digest_dir}/wiki/<slug>.md]]`.
- Useful predicates include `derived_from::`, `applies_to::`,
`relates_to::`, `depends_on::`, and `contradicts::`; predicates are open
and live outside the brackets.
- Wikilinks must be full workspace-relative paths with `.md`.
- CREATE writes inside `{digest_dir}/personal/`. UPDATE may target any bucket
if recall legitimately found the same abstraction.
@ -260,7 +252,7 @@ integrate_system_prompt_personal_zh: |
- 正文短且可操作,通常 50-200 words。
- 不要详细复述用户说了什么;用来源材料承载细节。
- Provenance edge 承载细节:用 `derived_from:: [[<path>]]` 引用 unit_paths 中每个相关来源。
- Sources 章节承载细节:用 `- [[<path>]]` 引用 unit_paths 中每个相关来源。
- Digest 之间的 wikilink 承载概念图。
## Personal 正文形态
@ -271,7 +263,7 @@ integrate_system_prompt_personal_zh: |
- `Why:` 原因或上下文,帮助未来判断边界情况。
- `How to apply:` 适用上下文、任务、边界或例外。
- 不要凭空添加例外,也不要软化明确偏好;只有来源材料明确支持时才写例外。
- `derived_from:: [[<material-path>]]`:至少一条,通常覆盖 unit_paths 中每个相关 path。
- `## Sources`:至少一条 `- [[<material-path>]]`,通常覆盖 unit_paths 中每个相关 path。
纯文本 provenance 不算;只有 wikilink 会在未来更新中保留下来。
偏好类内容优先一条偏好一个 node而不是一个人一个大 node这是下游搜索更容易命中的粒度。
@ -287,22 +279,20 @@ integrate_system_prompt_personal_zh: |
- unrelated忽略。
3. 选择且只选择一个 action
- CREATE没有 same_abstraction 命中;写入 `{digest_dir}/personal/<slug>.md`。
- CORROBORATE规则被再次确认追加 derived_from,可选强化置信度。
- CORROBORATE规则被再次确认追加来源链接,可选强化置信度。
- REFINEscope、条件、例外或例子变化扩展 `How to apply:`。
- CORRECT用户/团队改变主意或证据冲突;收紧到新旧证据都支持的表述,或用
`> note: contradicted by [[<path>]] - <one-line>` 内联标注。
4. CREATE 和 UPDATE 都要把 related digest 节点织入正文 wikilink。UPDATE 必须只增不删:不要删除已有
wikilink 或 derived_from。默认多织,而不是少织;这是挂接召回到的相关节点的唯一机会。
wikilink 或来源链接。默认多织,而不是少织;这是挂接召回到的相关节点的唯一机会。
## Wikilink 图
- 来源 provenance 从 digest 指回材料:
`derived_from:: [[daily/<date>/<session>.md]]` 或 `derived_from:: [[resource/<path>]]`。
- `## Sources` 章节用普通列表 Wikilink 从 digest 指回材料,例如
`- [[daily/<date>/<session>.md]]` 和 `- [[resource/<path>]]`。
- Personal 节点可以链接任意 digest bucket
`[[{digest_dir}/personal/<slug>.md]]`、`[[{digest_dir}/procedure/<slug>.md]]`、
`[[{digest_dir}/wiki/<slug>.md]]`。
- 常用 predicate`derived_from::`、`applies_to::`、`relates_to::`、`depends_on::`、
`contradicts::`predicate 词表开放,写在方括号外。
- Wikilink 必须是带 `.md` 的 workspace-relative path。
- CREATE 写入 `{digest_dir}/personal/`。如果 recall 合法命中同一抽象UPDATE 可以跨 bucket。
- `edit` 只改正文,并尽量只增不删:不要删除 old span 中已有的 wikilink。
@ -335,8 +325,8 @@ integrate_system_prompt_wiki: |
genuinely needs more.
- If the draft starts copying paragraphs or narrating the event, it belongs
in the source material, not digest.
- Provenance edges carry details: cite every relevant unit_paths entry with
`derived_from:: [[<path>]]`.
- The Sources section carries details: cite every relevant unit_paths entry
as `- [[<path>]]`.
- Digest-to-digest wikilinks carry the conceptual graph.
## Wiki Body Shape
@ -346,11 +336,10 @@ integrate_system_prompt_wiki: |
- First line: one-sentence definition or claim.
- Body: short paragraphs or tight bullets with properties, sub-claims,
distinctions, and one-line examples.
- Relations: typed wikilinks where the relation has semantic weight; most
cross-node links can stay bare.
- `derived_from:: [[<material-path>]]`: at least one, and normally every
relevant path in unit_paths. Plain-prose provenance does NOT count; only
wikilinks survive future updates.
- Relations: weave related nodes into natural prose with ordinary wikilinks.
- `## Sources`: at least one `- [[<material-path>]]` item, and normally one
for every relevant path in unit_paths. Plain-prose provenance does NOT
count; only wikilinks survive future updates.
## Workflow
@ -368,8 +357,8 @@ integrate_system_prompt_wiki: |
- unrelated: ignore.
3. Choose exactly one action:
- CREATE: no same_abstraction hit; write `{digest_dir}/wiki/<slug>.md`.
- CORROBORATE: the principle is reaffirmed by a new instance; append
derived_from and optionally strengthen wording.
- CORROBORATE: the principle is reaffirmed by a new instance; append its
source link and optionally strengthen wording.
- REFINE: nuance, scope, edge case, or framing changes; tighten the
relevant span. Body grows in precision, not detail volume.
- CORRECT: factual contradiction or overstatement; tighten to the narrower
@ -377,22 +366,18 @@ integrate_system_prompt_wiki: |
`> note: contradicted by [[<path>]] - <one-line>`.
4. Weave related digest nodes into the body as wikilinks on both CREATE and
UPDATE. UPDATE must be additive: never remove existing wikilinks or
derived_from entries. Default to weaving more, not less; this is the only
source links. Default to weaving more, not less; this is the only
chance to attach recalled related nodes.
## Wikilink Graph
- Source provenance links point from digest back to material:
`derived_from:: [[daily/<date>/<session>.md]]` or
`derived_from:: [[resource/<path>]]`.
- A `## Sources` section points from digest back to material with bare list
items such as `- [[daily/<date>/<session>.md]]` and
`- [[resource/<path>]]`.
- Wiki nodes may link to any digest bucket:
`[[{digest_dir}/wiki/<slug>.md]]`,
`[[{digest_dir}/procedure/<slug>.md]]`, or
`[[{digest_dir}/personal/<slug>.md]]`.
- Useful predicates include `derived_from::`, `is_a::`, `extends::`,
`depends_on::`, `relates_to::`, and `contradicts::`; predicates are open
and live outside the brackets. Most cross-node links can stay bare unless
the relation has clear semantic weight.
- Wikilinks must be full workspace-relative paths with `.md`.
- CREATE writes inside `{digest_dir}/wiki/`. UPDATE may target any bucket if
recall legitimately found the same abstraction.
@ -422,7 +407,7 @@ integrate_system_prompt_wiki_zh: |
- 正文短且抽象,通常 50-200 words只有概念本身确实需要时才更长。
- 如果草稿开始复制段落或叙述事件,说明细节放错层了。
- Provenance edge 承载细节:用 `derived_from:: [[<path>]]` 引用 unit_paths 中每个相关来源。
- Sources 章节承载细节:用 `- [[<path>]]` 引用 unit_paths 中每个相关来源。
- Digest 之间的 wikilink 承载概念图。
## Wiki 正文形态
@ -431,8 +416,8 @@ integrate_system_prompt_wiki_zh: |
- First line一句话定义或主张。
- Body短段落或紧凑 bullets写属性、子主张、区分和一行例子。
- Relations有明确语义重量时使用 typed wikilink大多数 cross-node link 可以裸写
- `derived_from:: [[<material-path>]]`:至少一条,通常覆盖 unit_paths 中每个相关 path。
- Relations在自然语言中用普通 Wikilink 织入相关节点
- `## Sources`:至少一条 `- [[<material-path>]]`,通常覆盖 unit_paths 中每个相关 path。
纯文本 provenance 不算;只有 wikilink 会在未来更新中保留下来。
## 工作流
@ -446,23 +431,20 @@ integrate_system_prompt_wiki_zh: |
- unrelated忽略。
3. 选择且只选择一个 action
- CREATE没有 same_abstraction 命中;写入 `{digest_dir}/wiki/<slug>.md`。
- CORROBORATE原则被新实例再次确认追加 derived_from,可选强化措辞。
- CORROBORATE原则被新实例再次确认追加来源链接,可选强化措辞。
- REFINEnuance、scope、edge case 或 framing 改变;收紧相关段落。正文增长的是精度,不是细节量。
- CORRECT事实冲突或过度概括收紧到更窄且有支持的表述或用
`> note: contradicted by [[<path>]] - <one-line>` 内联标注。
4. CREATE 和 UPDATE 都要把 related digest 节点织入正文 wikilink。UPDATE 必须只增不删:不要删除已有
wikilink 或 derived_from。默认多织,而不是少织;这是挂接召回到的相关节点的唯一机会。
wikilink 或来源链接。默认多织,而不是少织;这是挂接召回到的相关节点的唯一机会。
## Wikilink 图
- 来源 provenance 从 digest 指回材料:
`derived_from:: [[daily/<date>/<session>.md]]` 或 `derived_from:: [[resource/<path>]]`。
- `## Sources` 章节用普通列表 Wikilink 从 digest 指回材料,例如
`- [[daily/<date>/<session>.md]]` 和 `- [[resource/<path>]]`。
- Wiki 节点可以链接任意 digest bucket
`[[{digest_dir}/wiki/<slug>.md]]`、`[[{digest_dir}/procedure/<slug>.md]]`、
`[[{digest_dir}/personal/<slug>.md]]`。
- 常用 predicate`derived_from::`、`is_a::`、`extends::`、`depends_on::`、
`relates_to::`、`contradicts::`predicate 词表开放,写在方括号外。有明确语义重量时才加
predicate大多数 cross-node link 可以裸写。
- Wikilink 必须是带 `.md` 的 workspace-relative path。
- CREATE 写入 `{digest_dir}/wiki/`。如果 recall 合法命中同一抽象UPDATE 可以跨 bucket。
- `edit` 只改正文,并尽量只增不删:不要删除 old span 中已有的 wikilink。
@ -489,8 +471,8 @@ integrate_user_message: |
{material_blob}
Integrate this single unit into digest memory. Cite every relevant unit_paths
entry with `derived_from:: [[<path>]]`, recall related digest nodes, and weave
useful digest wikilinks into the target node.
entry as `- [[<path>]]` under `## Sources`, recall related digest nodes, and
weave useful digest wikilinks into the target node.
integrate_user_message_zh: |
提示:{hint}
@ -506,5 +488,5 @@ integrate_user_message_zh: |
{material_blob}
将这个单独的 unit 整合进 digest memory。用 `derived_from:: [[<path>]]` 引用 unit_paths 中每个相关来源,
召回相关 digest 节点,并把有用的 digest wikilink 织入目标节点。
将这个单独的 unit 整合进 digest memory。在 `## Sources` 下用 `- [[<path>]]` 引用 unit_paths 中每个
相关来源,召回相关 digest 节点,并把有用的 digest wikilink 织入目标节点。

View file

@ -71,10 +71,10 @@ def scan_day_files(workspace: Path, day: str, daily: str, interests_name: str =
out: list[str] = []
day_index = workspace / daily / f"{day}.md"
if day_index.is_file():
out.append(str(day_index.relative_to(workspace)))
out.append(day_index.relative_to(workspace).as_posix())
daily_root = workspace / daily / day
if daily_root.is_dir():
out.extend(str(p.relative_to(workspace)) for p in sorted(daily_root.rglob("*.md")) if p.is_file())
out.extend(p.relative_to(workspace).as_posix() for p in sorted(daily_root.rglob("*.md")) if p.is_file())
return [p for p in out if p != f"{daily}/{day}/{interests_name}"]

View file

@ -65,6 +65,7 @@ class MoveStep(BaseStep):
)
self.context.response.metadata.update(payload)
# pylint: disable=too-many-return-statements
async def _move(self, src_path: str, dst_path: str, overwrite: bool, retarget: bool) -> dict:
workspace_dir = Path(self.file_store.workspace_path or ".").resolve()
src_abs, src_err = resolve_path(workspace_dir, src_path) if src_path else (None, "src_path is required")
@ -79,7 +80,8 @@ class MoveStep(BaseStep):
assert src_abs is not None and dst_abs is not None # narrowed by precheck
dst_abs.parent.mkdir(parents=True, exist_ok=True)
# Step 1 — copy. Both files exist; inbound [[src_path]] still resolves.
# Step 1 — copy. Wikilinks use workspace-relative paths, so outgoing
# targets do not change when their containing document moves.
shutil.copyfile(str(src_abs), str(dst_abs))
payload: dict = {"src_path": src_path, "dst_path": dst_path, "size": dst_abs.stat().st_size}
@ -91,10 +93,13 @@ class MoveStep(BaseStep):
try:
report = await WikilinkHandler.retarget_links(self.file_store, src=src_path, dst=dst_path)
except Exception as exc:
payload["retarget"] = {"error": f"retarget raised: {exc!r}"}
error = f"retarget raised: {exc!r}"
payload["error"] = error
payload["retarget"] = {"error": error}
payload["src_removed"] = False
return payload
if "error" in report:
payload["error"] = report["error"]
payload["retarget"] = report
payload["src_removed"] = False
return payload

View file

@ -168,7 +168,7 @@ class ReadStep(BaseStep):
"""Append the rendered neighbor block + stash raw expansion in metadata."""
assert self.context is not None
try:
rel_path = str(target.relative_to(self.workspace_path))
rel_path = target.relative_to(self.workspace_path.resolve()).as_posix()
except ValueError:
self.logger.info(f"[{self.name}] skip neighbors: path outside workspace_path path={target}")
return

View file

@ -1,10 +1,10 @@
"""BFS over wikilink edges from one or more seed files.
One record per traversed *edge* (not per node): the same target can repeat
if reached via different predicates or paths. Each record carries the
predecessor plus the link's predicate/anchor so callers can reconstruct
the path. Adjacency is built once via a single ``file_store.get_nodes()``
call BFS then runs purely in memory with no per-frontier round-trips.
if reached via different anchors or paths. Each record carries the
predecessor and anchor so callers can reconstruct the path. Adjacency is
built once via a single ``file_store.get_nodes()`` call BFS then runs
purely in memory with no per-frontier round-trips.
"""
from collections import deque
@ -46,7 +46,7 @@ def _bfs(
outbound: Adjacency,
inbound: Adjacency,
) -> list[dict]:
"""In-memory BFS; emits one record per unique (src, dst, predicate) edge."""
"""In-memory BFS; emits one record per unique (src, dst, anchor) edge."""
sources: list[Adjacency] = []
if direction in _OUT:
sources.append(outbound)
@ -63,7 +63,7 @@ def _bfs(
continue
for src in sources:
for next_path, link in src.get(current, ()):
key = (current, next_path, link.predicate)
key = (current, next_path, link.target_anchor)
if key in visited:
continue
visited.add(key)
@ -72,7 +72,6 @@ def _bfs(
"path": next_path,
"depth": depth + 1,
"via": current,
"predicate": link.predicate,
"anchor": link.target_anchor,
},
)
@ -119,8 +118,7 @@ class TraverseStep(BaseStep):
target = r["path"]
if r["anchor"]:
target = f"{target}#{r['anchor']}"
predicate = r["predicate"] or "-"
lines.append(f"[depth={r['depth']}] {r['via']} --{predicate}--> {target}")
lines.append(f"[depth={r['depth']}] {r['via']} --> {target}")
answer = "\n".join(lines)
self.context.response.success = True

View file

@ -9,6 +9,7 @@ from .common_utils import (
)
from .env_utils import load_env, parse_env_file
from .link_expansion import expand_links, render_expansion_lines
from .line_anchor import format_line_anchor, parse_line_anchor
from .logger_utils import get_logger
from .logo_utils import print_logo
from .service_utils import find_reme, locate_reme, precheck_start, cli_find_reme, running_service_config
@ -33,6 +34,8 @@ __all__ = [
"parse_env_file",
"expand_links",
"render_expansion_lines",
"format_line_anchor",
"parse_line_anchor",
"get_logger",
"print_logo",
"find_reme",

43
reme/utils/line_anchor.py Normal file
View file

@ -0,0 +1,43 @@
"""Parse and format GitHub-style 1-based line-anchor strings."""
import re
_LINE_ANCHOR_RE = re.compile(r"L[0-9]+(?:-L[0-9]+)?(?:,L[0-9]+(?:-L[0-9]+)?)*")
def parse_line_anchor(anchor: str | None) -> list[tuple[int, int]] | None:
"""Return normalized inclusive ranges, or ``None`` for a non-line anchor.
Supported forms are ``L9``, ``L9-L10`` and
``L9-L10,L15-L20``. Overlapping and adjacent ranges are merged.
Anchors beginning with ``L<digit>`` are treated as line anchors and raise
``ValueError`` when malformed, zero-based, or reversed.
"""
if not anchor or not re.match(r"L[0-9]", anchor):
return None
if not _LINE_ANCHOR_RE.fullmatch(anchor):
raise ValueError(f"invalid line anchor: #{anchor}")
ranges: list[tuple[int, int]] = []
for item in anchor.split(","):
start_text, separator, end_text = item.partition("-L")
start = int(start_text[1:])
end = int(end_text) if separator else start
if start < 1 or end < 1:
raise ValueError("line numbers must be at least 1")
if start > end:
raise ValueError(f"line range start ({start}) exceeds end ({end})")
ranges.append((start, end))
merged: list[tuple[int, int]] = []
for start, end in sorted(ranges):
if merged and start <= merged[-1][1] + 1:
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
else:
merged.append((start, end))
return merged
def format_line_anchor(ranges: list[tuple[int, int]]) -> str:
"""Render normalized ranges without the leading ``#``."""
return ",".join(f"L{start}" if start == end else f"L{start}-L{end}" for start, end in ranges)

View file

@ -8,7 +8,7 @@ Two-layer split so callers can pick what they need:
* :func:`expand_links` data layer. Returns a structured dict keyed
by source path, each value carrying its outlinks / inlinks with
neighbor meta and per-edge predicate/anchor.
neighbor metadata and link anchors.
* :func:`render_expansion_lines` view layer. Turns one path's
expansion sub-dict into the same `` path name= description=``
block ``SearchStep`` has historically printed.
@ -19,16 +19,16 @@ import asyncio
from ..schema import FileLink, FileNode
def _group_by_neighbor(links: list[FileLink], key_attr: str) -> dict[str, list[dict]]:
"""Group edges by neighbor path (insertion-ordered), each value a list of {predicate, anchor}."""
out: dict[str, list[dict]] = {}
def _group_by_neighbor(links: list[FileLink], key_attr: str) -> dict[str, list[str]]:
"""Group anchors by neighbor path while preserving insertion order."""
out: dict[str, list[str]] = {}
for lnk in links:
neighbor = getattr(lnk, key_attr)
if not neighbor:
continue
out.setdefault(neighbor, []).append(
{"predicate": lnk.predicate, "anchor": lnk.target_anchor},
)
anchors = out.setdefault(neighbor, [])
if lnk.target_anchor:
anchors.append(lnk.target_anchor)
return out
@ -55,16 +55,6 @@ def _format_meta_inline(meta: dict) -> str:
return " ".join(parts) if parts else "(no meta)"
def _format_via(edge: dict) -> str:
"""Render a single (predicate, anchor) edge as a 'via ...' descriptor."""
bits = []
if edge.get("predicate"):
bits.append(f"predicate={edge['predicate']}")
if edge.get("anchor"):
bits.append(f"anchor=#{edge['anchor']}")
return ", ".join(bits) if bits else "plain"
async def expand_links(
file_store,
paths: list[str],
@ -73,7 +63,7 @@ async def expand_links(
"""Fetch out/in links for each path and attach neighbor meta.
Returns ``{path: {"outlinks": [...], "inlinks": [...]}, ...}`` where
each list item is ``{"path": str, "meta": {...}, "edges": [{"predicate", "anchor"}, ...]}``.
each list item is ``{"path": str, "meta": {...}, "anchors": [str, ...]}``.
Empty input returns ``{}``. ``max_per_direction`` caps the neighbor
list per direction *before* meta lookup so we don't fetch nodes
that won't be displayed.
@ -95,9 +85,10 @@ async def expand_links(
nodes = await file_store.get_nodes(neighbor_paths) if neighbor_paths else []
meta_by_path = {n.path: _node_meta(n) for n in nodes}
def _attach(grouped: dict[str, list[dict]]) -> list[dict]:
def _attach(grouped: dict[str, list[str]]) -> list[dict]:
return [
{"path": npath, "meta": meta_by_path.get(npath, {}), "edges": edges} for npath, edges in grouped.items()
{"path": npath, "meta": meta_by_path.get(npath, {}), "anchors": anchors}
for npath, anchors in grouped.items()
]
return {p: {"outlinks": _attach(og), "inlinks": _attach(ig)} for p, og, ig in zip(paths, out_grouped, in_grouped)}
@ -124,6 +115,6 @@ def render_expansion_lines(expansion: dict, indent: str = " ") -> list[str]:
lines.append(f"{indent}{direction} ({len(items)}):")
for item in items:
lines.append(f"{inner}{arrow} {item['path']} {_format_meta_inline(item['meta'])}")
for edge in item["edges"]:
lines.append(f"{edge_indent}via {_format_via(edge)}")
for anchor in item["anchors"]:
lines.append(f"{edge_indent}via anchor=#{anchor}")
return lines

View file

@ -2,7 +2,7 @@
One class, :class:`WikilinkHandler`, owning every wikilink concern:
* **Pure text** regex, Dataview predicate inference, validation:
* **Pure text** regex, extraction, rewrite, validation:
:meth:`~WikilinkHandler.extract_links` (used by
:mod:`reme.components.file_chunker.markdown_file_chunker`),
:meth:`~WikilinkHandler.scan_and_rewrite`,
@ -16,13 +16,12 @@ One class, :class:`WikilinkHandler`, owning every wikilink concern:
post-rename to point inbound ``[[src]]`` at the new path). Source
candidates come from the file_graph's reverse index — no fs scan.
Wikilink convention. Targets are taken **literally** ``[[X]]``
Wikilink targets are taken **literally** ``[[X]]``
``target="X"``, no implicit ``.md``, no short-form basename search,
no folder-note expansion. Anchor and alias survive a rewrite
verbatim. Image marker (``!``) and Dataview predicate (``pred::``
outside the brackets) sit outside ``[[...]]`` and are not touched by
a rewrite. Recommended form: full path relative to the workspace with
extension (``[[topics/x.md]]``).
verbatim. Text outside ``[[...]]`` is ignored. Recommended form: full
path relative to the workspace with extension (``[[topics/x.md]]``).
Ordinary Markdown links such as ``[label](path.md)`` are ignored.
Stale graph entries are harmless (``scan_and_rewrite`` returns
count=0 and the file is skipped), but a graph missing recent writes
@ -37,26 +36,23 @@ from ..enumeration import LinkScopeEnum
from ..schema import FileLink
def _normalize_workspace_path(path: str) -> str:
"""Use POSIX separators for workspace paths on every platform."""
return path.replace("\\", "/")
@dataclass(frozen=True)
class WikilinkMatch:
"""One ``[[...]]`` occurrence with parts surfaced.
``anchor`` / ``alias`` are stored **without** the leading ``#`` /
``|`` so they map cleanly to :class:`FileLink.target_anchor`; the
rewrite path reads the raw regex groups (with delimiters) directly
and doesn't go through this dataclass.
"""
"""The graph-relevant parts and source span of one wikilink."""
target: str
anchor: str | None
alias: str | None
bang: bool
start: int
end: int
class WikilinkHandler:
"""Pure-text wikilink operations: parse, extract, rewrite, validate."""
"""Parse, extract, rewrite, and validate wikilinks."""
# Captures: optional image marker (``!``), the bare target, an
# optional ``#anchor`` slice (with ``#``), and an optional ``|alias``
@ -77,54 +73,39 @@ class WikilinkHandler:
FORBIDDEN_IN_NEW = ("[", "]", "#", "|", "\n", "\r")
_DATAVIEW_LINE_RE = re.compile(
r"^[ \t]*(?:[-*+][ \t]+)?(?P<predicate>[A-Za-z][A-Za-z0-9_]*)\s*::\s*(?P<value>.+?)\s*$",
re.MULTILINE,
)
_INLINE_FIELD_OPEN_RE = re.compile(r"\[(?P<predicate>[A-Za-z][A-Za-z0-9_]*)\s*::\s*")
# -- Low-level scan ------------------------------------------------
@classmethod
def iter_matches(cls, text: str):
"""Yield :class:`WikilinkMatch` for every ``[[...]]`` in ``text``.
Skips matches whose target is empty after strip (defensive).
"""
"""Yield every non-empty ``[[...]]`` occurrence in ``text``."""
for m in cls.WIKILINK_RE.finditer(text):
target = m.group("target").strip()
if not target:
continue
anchor_raw = m.group("anchor")
alias_raw = m.group("alias")
yield WikilinkMatch(
target=target,
anchor=anchor_raw[1:].strip() if anchor_raw else None,
alias=alias_raw[1:].strip() if alias_raw else None,
bang=bool(m.group("bang")),
start=m.start(),
end=m.end(),
)
# -- FileLink extraction (with predicate inference) ---------------
# -- FileLink extraction ------------------------------------------
@classmethod
def extract_links(cls, text: str, source_path: str) -> list[FileLink]:
"""Emit :class:`FileLink` edges for every wikilink in ``text``.
No resolution: ``target_path`` is the bracket contents verbatim.
Results are deduped by ``(target_path, predicate, target_anchor)``
preserving order.
Targets remain literal. Results are deduped by
``(target_path, target_anchor)`` while preserving order.
"""
if not text:
return []
inline_spans = cls._iter_inline_fields(text)
source_path = _normalize_workspace_path(source_path)
out: list[FileLink] = []
seen: set[tuple] = set()
for wm in cls.iter_matches(text):
predicate = cls._predicate_for(text, wm.start, inline_spans)
key = (wm.target, predicate, wm.anchor)
key = (wm.target, wm.anchor)
if key in seen:
continue
seen.add(key)
@ -133,7 +114,6 @@ class WikilinkHandler:
source_path=source_path,
target_path=wm.target,
target_anchor=wm.anchor,
predicate=predicate,
),
)
return out
@ -147,20 +127,19 @@ class WikilinkHandler:
old: str,
new: str | None,
) -> tuple[str, int]:
"""Find (and optionally rewrite) wikilinks whose target equals ``old``.
"""Find and optionally rewrite wikilinks whose target is ``old``.
Returns ``(new_text, count)``. When ``new`` is ``None`` no rewrite
happens (the original text is returned), but the count is still
populated used by ``find_inbound``. Matching is literal:
``target == old``. No short-link, no implicit ``.md``, no
folder-note expansion.
populated used by ``find_inbound``. Matching is literal.
"""
old = _normalize_workspace_path(old)
new = _normalize_workspace_path(new) if new is not None else None
count = 0
def sub(match: re.Match) -> str:
nonlocal count
target = match.group("target").strip()
if target != old:
if match.group("target").strip() != old:
return match.group(0)
count += 1
if new is None:
@ -170,8 +149,7 @@ class WikilinkHandler:
bang = match.group("bang") or ""
return f"{bang}[[{new}{anchor}{alias}]]"
new_text = cls.WIKILINK_RE.sub(sub, text)
return new_text, count
return cls.WIKILINK_RE.sub(sub, text), count
# -- Validation ----------------------------------------------------
@ -201,53 +179,6 @@ class WikilinkHandler:
prefix = scope.rstrip("/") + "/"
return rel == scope or rel.startswith(prefix)
# -- Predicate helpers (internal) ---------------------------------
@classmethod
def _iter_inline_fields(cls, text: str) -> list[tuple[int, int, str]]:
"""Find inline-bracketed ``[predicate:: …]`` field spans by depth scan."""
out: list[tuple[int, int, str]] = []
for m in cls._INLINE_FIELD_OPEN_RE.finditer(text):
depth = 1
i = m.end()
n = len(text)
while i < n:
c = text[i]
if c == "\n":
break
if c == "[":
depth += 1
elif c == "]":
depth -= 1
if depth == 0:
out.append((m.start(), i + 1, m.group("predicate")))
break
i += 1
return out
@classmethod
def _predicate_for(
cls,
text: str,
pos: int,
inline_spans: list[tuple[int, int, str]],
) -> str | None:
"""Resolve the predicate governing a wikilink at offset ``pos``.
Precedence: inline-bracketed > line-level Dataview > none.
"""
for field_start, field_end, predicate in inline_spans:
if field_start <= pos < field_end:
return predicate
line_start = text.rfind("\n", 0, pos) + 1
line_end = text.find("\n", pos)
if line_end == -1:
line_end = len(text)
m = cls._DATAVIEW_LINE_RE.match(text[line_start:line_end])
if m and line_start + m.start("value") <= pos:
return m.group("predicate")
return None
# -- Async file_graph-aware operations -----------------------------
@classmethod
@ -260,11 +191,12 @@ class WikilinkHandler:
required to surface sources whose edges sit in the pending bucket.
Each returned ``FileLink`` carries the linking node's ``source_path``;
we dedupe to a sorted list since one source can host multiple edges
(different anchor/predicate) to the same target. Returns ``[]`` when
(different anchors) to the same target. Returns ``[]`` when
there is no file_graph attached or no source references the target.
"""
if not file_store.file_graph:
return []
target = _normalize_workspace_path(target)
inlinks = await file_store.file_graph.get_inlinks(target, scope=LinkScopeEnum.ALL)
return sorted({link.source_path for link in inlinks if link.source_path})
@ -272,12 +204,10 @@ class WikilinkHandler:
async def find_inbound(cls, file_store, target: str, scope: str = "") -> dict:
"""Count wikilinks across the workspace that point at ``target``.
Literal matching: ``[[target]]`` only. The target file itself is
excluded self-references don't survive a delete and aren't
actionable for the caller. Sources come from the file_graph's
reverse index; per-file counts come from reading each candidate
source (the graph dedupes by ``(target, predicate, anchor)`` so
it can't count repeated bare-wikilink occurrences directly).
The target file itself is excluded self-references don't survive a
delete and aren't actionable for the caller. Sources come from the
file_graph's reverse index; per-file counts come from reading each
candidate source because the graph dedupes repeated edges.
Result shape::
@ -291,6 +221,8 @@ class WikilinkHandler:
On bad inputs returns ``{"target": ..., "error": str}``.
"""
target = _normalize_workspace_path(target)
scope = _normalize_workspace_path(scope)
if not target:
return {"target": target, "error": "target is required"}
if Path(target).is_absolute():
@ -340,6 +272,9 @@ class WikilinkHandler:
matching only; candidate sources come from the file_graph's reverse
index.
"""
src = _normalize_workspace_path(src)
dst = _normalize_workspace_path(dst)
scope = _normalize_workspace_path(scope)
err = cls.validate_src_dst(src, dst)
if err is not None:
return {"src": src, "dst": dst, "error": err}

View file

@ -84,7 +84,8 @@ token used to assert identity and claims between parties.
## Related
Often issued by [[digest/wiki/oauth2.md]] flows.
derived_from:: [[daily/2026-05-15/auth-design/notes.md]]
## Sources
- [[daily/2026-05-15/auth-design/notes.md]]
""",
"digest/wiki/oauth2.md": """\
---
@ -103,7 +104,8 @@ issued by an authorization server.
- Client credentials
- Refresh token
derived_from:: [[daily/2026-05-10/oauth-intro/notes.md]]
## Sources
- [[daily/2026-05-10/oauth-intro/notes.md]]
""",
"digest/procedure/key-rotation.md": """\
---
@ -127,7 +129,8 @@ issuance.
Default rotation cadence is **30 days**. Driven by historical practice;
no formal compliance requirement has tightened this so far.
derived_from:: [[daily/2026-05-20/rotation-plan/notes.md]]
## Sources
- [[daily/2026-05-20/rotation-plan/notes.md]]
""",
"digest/personal/no-trailing-summary.md": """\
---
@ -144,7 +147,8 @@ description: 不要在回复末尾加总结段落
**How to apply**: 任意编码 / 编辑任务回复结束时,直接停在最后一条
有信息量的话上,不要再补一段"以上就是本次的修改..."
derived_from:: [[daily/2026-05-01/style-feedback/notes.md]]
## Sources
- [[daily/2026-05-01/style-feedback/notes.md]]
""",
# ----- daily provenance stubs (so the digest links don't dangle) -----
"daily/2026-05-01/style-feedback/notes.md": """\

View file

@ -182,15 +182,13 @@ def test_auto_dream_and_proactive():
if (env.workspace_dir / rel).is_file()
}
digest_wikilinks = [rel for rel, text in target_texts.items() if "[[digest/" in text]
provenance_links = [
rel for rel, text in target_texts.items() if f"derived_from:: [[{DREAM_INPUT_PATH}]]" in text
]
source_links = [rel for rel, text in target_texts.items() if f"- [[{DREAM_INPUT_PATH}]]" in text]
print(f"[wikilink] integrated targets: {target_paths}")
print(f"[wikilink] integrated targets with [[digest/...]] links: {digest_wikilinks}")
print(f"[wikilink] integrated targets with derived_from source links: {provenance_links}")
print(f"[wikilink] integrated targets with source links: {source_links}")
assert target_texts, f"no integrated target files found: {target_paths}"
assert provenance_links, (
"no derived_from wikilink back to the changed daily note in integrated targets\n"
assert source_links, (
"no source wikilink back to the changed daily note in integrated targets\n"
f"targets: {target_paths}"
)
assert digest_wikilinks, (

View file

@ -11,7 +11,7 @@ from reme.components.application_context import ApplicationContext
from reme.components.file_catalog import BaseFileCatalog
from reme.components.file_store import BaseFileStore
from reme.components.runtime_context import RuntimeContext
from reme.schema import DreamState
from reme.schema import DreamState, FileNode
from reme.steps.evolve.dream.extract import DreamExtractStep
from reme.steps.evolve.dream.finish import DreamFinishStep
from reme.steps.evolve.dream.proactive import ProactiveStep
@ -94,6 +94,48 @@ def test_scan_day_files_includes_nested_md_and_excludes_interests():
]
def test_dream_extract_matches_posix_catalog_paths(tmp_path):
"""Unchanged nested files retain their POSIX catalog entries on every platform."""
class Catalog(_Catalog):
"""Catalog seeded with POSIX paths and recording deletions."""
def __init__(self, nodes):
super().__init__()
self.nodes = nodes
self.deleted = []
async def delete(self, path):
self.deleted.extend(path if isinstance(path, list) else [path])
async def get_nodes(self, paths=None):
return self.nodes
async def run():
note = _touch(tmp_path / "daily" / "2026-05-28" / "nested" / "session.md")
rel_path = note.relative_to(tmp_path).as_posix()
catalog = Catalog([FileNode(path=rel_path, st_mtime=note.stat().st_mtime)])
step = DreamExtractStep(scan_days=1, app_context=ApplicationContext(workspace_dir=str(tmp_path)))
with patch("reme.steps.evolve.dream.extract.refresh_day_index", return_value={}):
response = await step(
RuntimeContext(
date="2026-05-28",
file_catalog=catalog,
file_store=_FileStore(tmp_path),
),
)
dream = response.metadata["dream"]
assert response.success is True
assert dream["unchanged_paths"] == [rel_path]
assert dream["changed_paths"] == []
assert dream["deleted_paths"] == []
assert not catalog.deleted
asyncio.run(run())
def test_recent_dates_includes_anchor_and_previous_days():
"""Recent date window is inclusive and chronological."""
assert recent_dates("2026-05-28", 3) == ["2026-05-26", "2026-05-27", "2026-05-28"]
@ -152,7 +194,7 @@ def test_extract_without_llm_marks_changed_paths_failed(tmp_path):
dream = response.metadata["dream"]
assert response.success is False
assert str(note.relative_to(tmp_path)) in dream["changed_paths"]
assert note.relative_to(tmp_path).as_posix() in dream["changed_paths"]
assert dream["failed_paths"] == dream["changed_paths"]
asyncio.run(run())
@ -299,9 +341,9 @@ def test_finish_does_not_checkpoint_failed_changed_paths():
dates=["2026-05-26", "2026-05-27", "2026-05-28"],
workspace=str(workspace),
daily_dir="daily",
changed_paths=[str(ok.relative_to(workspace)), str(failed.relative_to(workspace))],
failed_paths=[str(failed.relative_to(workspace))],
interests_paths=[str(interests.relative_to(workspace))],
changed_paths=[ok.relative_to(workspace).as_posix(), failed.relative_to(workspace).as_posix()],
failed_paths=[failed.relative_to(workspace).as_posix()],
interests_paths=[interests.relative_to(workspace).as_posix()],
integrate_results=[
{
"action": "CREATE",
@ -320,10 +362,10 @@ def test_finish_does_not_checkpoint_failed_changed_paths():
assert "topics:" not in resp.answer
assert "Changes:" in resp.answer
assert "- [digest/procedure/example.md][CREATE]: Created a concise procedure node." in resp.answer
assert str(ok.relative_to(workspace)) in upserted
assert str(failed.relative_to(workspace)) not in upserted
assert str(interests.relative_to(workspace)) in upserted
assert str(day_index.relative_to(workspace)) in upserted
assert ok.relative_to(workspace).as_posix() in upserted
assert failed.relative_to(workspace).as_posix() not in upserted
assert interests.relative_to(workspace).as_posix() in upserted
assert day_index.relative_to(workspace).as_posix() in upserted
assert catalog.dumps == 1
asyncio.run(run())

View file

@ -306,7 +306,7 @@ def test_to_workspace_relative_inside_workspace():
mixin = ComponentMixin()
abs_path = mixin.workspace_path / "sub" / "file.md"
rel = mixin.to_workspace_relative(abs_path)
assert rel == str(abs_path.relative_to(mixin.workspace_path))
assert rel == abs_path.relative_to(mixin.workspace_path).as_posix()
finally:
os.chdir(old_cwd)

View file

@ -42,12 +42,14 @@ def _run(coro):
asyncio.run(coro)
def _node(path: str, links: list[tuple[str, str | None, str | None]] | None = None) -> FileNode:
"""Build a FileNode with (target_path, target_anchor, predicate) outgoing edges."""
def _node(path: str, links: list[tuple[str, str | None]] | None = None) -> FileNode:
"""Build a FileNode with (target_path, target_anchor) outgoing edges."""
return FileNode(
path=path,
st_mtime=1.0,
links=[FileLink(source_path=path, target_path=t, target_anchor=a, predicate=p) for t, a, p in (links or [])],
links=[
FileLink(source_path=path, target_path=target, target_anchor=anchor) for target, anchor in (links or [])
],
)
@ -197,7 +199,7 @@ def test_traverse_forward_depth_1():
with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
store = await _make_store(
[
_node("a.md", [("b.md", None, None), ("c.md", "intro", "ref")]),
_node("a.md", [("b.md", None), ("c.md", "intro")]),
_node("b.md"),
_node("c.md"),
],
@ -207,9 +209,8 @@ def test_traverse_forward_depth_1():
results = _edges(step)
paths = {r["path"] for r in results}
assert paths == {"b.md", "c.md"}
# The 'ref' edge should report its predicate/anchor.
# Anchors remain part of traversal metadata.
c_edge = next(r for r in results if r["path"] == "c.md")
assert c_edge["predicate"] == "ref"
assert c_edge["anchor"] == "intro"
assert c_edge["via"] == "a.md"
assert c_edge["depth"] == 1
@ -226,8 +227,8 @@ def test_traverse_backward_returns_inlinks():
with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
store = await _make_store(
[
_node("a.md", [("b.md", None, None)]),
_node("c.md", [("b.md", None, None)]),
_node("a.md", [("b.md", None)]),
_node("c.md", [("b.md", None)]),
_node("b.md"),
],
)
@ -248,8 +249,8 @@ def test_traverse_depth_2_expands():
with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
store = await _make_store(
[
_node("a.md", [("b.md", None, None)]),
_node("b.md", [("c.md", None, None)]),
_node("a.md", [("b.md", None)]),
_node("b.md", [("c.md", None)]),
_node("c.md"),
],
)
@ -311,8 +312,8 @@ def test_traverse_both_directions():
with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
store = await _make_store(
[
_node("upstream.md", [("center.md", None, None)]),
_node("center.md", [("downstream.md", None, None)]),
_node("upstream.md", [("center.md", None)]),
_node("center.md", [("downstream.md", None)]),
_node("downstream.md"),
],
)

View file

@ -105,7 +105,6 @@ def test_parse_links_bare():
assert link.source_path == "src.md"
assert link.target_path == "note"
assert link.target_anchor is None
assert link.predicate is None
print("✓ test_parse_links_bare passed")
@ -115,7 +114,6 @@ def test_parse_links_with_anchor():
assert len(links) == 1
assert links[0].target_path == "note"
assert links[0].target_anchor == "section A"
assert links[0].predicate is None
print("✓ test_parse_links_with_anchor passed")
@ -137,47 +135,18 @@ def test_parse_links_anchor_and_alias():
print("✓ test_parse_links_anchor_and_alias passed")
def test_parse_links_predicate_simple():
"""Dataview inline: predicate:: [[target]]."""
links = WikilinkHandler.extract_links("author:: [[Alice]]", "src.md")
assert len(links) == 1
assert links[0].predicate == "author"
assert links[0].target_path == "Alice"
assert links[0].target_anchor is None
print("✓ test_parse_links_predicate_simple passed")
def test_parse_links_predicate_bracketed():
"""Dataview inline-bracket: [predicate:: [[target]]]."""
links = WikilinkHandler.extract_links("text [author:: [[Alice]]] more", "src.md")
assert len(links) == 1
assert links[0].predicate == "author"
assert links[0].target_path == "Alice"
print("✓ test_parse_links_predicate_bracketed passed")
def test_parse_links_predicate_bracketed_with_anchor():
"""[predicate:: [[target_path#target_anchor]]] — combined form."""
def test_parse_links_ignores_legacy_relation_wrappers():
"""Legacy relation text remains compatible as ordinary wikilinks."""
links = WikilinkHandler.extract_links(
"[predicate:: [[target_path#target_anchor]]]",
"related:: [[a]]\n- related:: [[b]]\n[related:: [[c#section]]]",
"src.md",
)
assert len(links) == 1
link = links[0]
assert link.source_path == "src.md"
assert link.predicate == "predicate"
assert link.target_path == "target_path"
assert link.target_anchor == "target_anchor"
print("✓ test_parse_links_predicate_bracketed_with_anchor passed")
def test_parse_links_predicate_sticks_to_first():
"""Line-level predicate covers all wikilinks in its value portion."""
links = WikilinkHandler.extract_links("pred:: [[a]] and bare [[b]]", "src.md")
assert len(links) == 2
assert links[0].predicate == "pred" and links[0].target_path == "a"
assert links[1].predicate == "pred" and links[1].target_path == "b"
print("✓ test_parse_links_predicate_sticks_to_first passed")
assert [(link.target_path, link.target_anchor) for link in links] == [
("a", None),
("b", None),
("c", "section"),
]
assert all(link.predicate is None for link in links)
def test_parse_links_multiple_on_one_line():
@ -190,6 +159,30 @@ def test_parse_links_multiple_on_one_line():
print("✓ test_parse_links_multiple_on_one_line passed")
def test_parse_wikilink_line_ranges():
"""Workspace paths and supported line-range forms create FileLink edges."""
links = WikilinkHandler.extract_links(
"[[daily/2026-06-20/session.md]] [[notes/example.md#L9]] "
+ "[[notes/example.md#L9-L10]] [[notes/example.md#L9-L10,L15-L20]]",
"src.md",
)
assert [(link.target_path, link.target_anchor) for link in links] == [
("daily/2026-06-20/session.md", None),
("notes/example.md", "L9"),
("notes/example.md", "L9-L10"),
("notes/example.md", "L9-L10,L15-L20"),
]
def test_parse_markdown_links_are_ignored():
"""Ordinary Markdown links do not create FileLink edges."""
links = WikilinkHandler.extract_links(
"[plain](../wiki/a.md) [section](../wiki/a.md#section) [ranges](../wiki/b.md#L9-L10,L15-L20)",
"daily/note.md",
)
assert not links
def test_parse_links_no_match():
"""Strings without [[]] yield no links, even if '::' appears."""
assert len(WikilinkHandler.extract_links("no link here :: foo", "src.md")) == 0
@ -198,15 +191,6 @@ def test_parse_links_no_match():
print("✓ test_parse_links_no_match passed")
def test_parse_links_predicate_with_underscore_and_digits():
"""Predicate identifier accepts letters, digits, underscore (no dash per Dataview spec)."""
links = WikilinkHandler.extract_links("see_also2:: [[target]]", "src.md")
assert len(links) == 1
assert links[0].predicate == "see_also2"
assert links[0].target_path == "target"
print("✓ test_parse_links_predicate_with_underscore_and_digits passed")
def test_parse_links_in_file():
"""Integration: parse() populates FileNode.links from file content."""
@ -227,11 +211,8 @@ def test_parse_links_in_file():
try:
chunker = DefaultFileChunker()
file_node, _ = await chunker.chunk(temp_path)
triples = {(link.predicate, link.target_path, link.target_anchor) for link in file_node.links}
assert (None, "alpha", None) in triples
assert (None, "beta", "h2") in triples
assert ("author", "Alice", None) in triples
assert ("ref", "paper", "chapter 1") in triples
pairs = {(link.target_path, link.target_anchor) for link in file_node.links}
assert pairs == {("alpha", None), ("beta", "h2"), ("Alice", None), ("paper", "chapter 1")}
assert all(link.source_path == file_node.path for link in file_node.links)
print("✓ test_parse_links_in_file passed")
finally:
@ -384,13 +365,11 @@ if __name__ == "__main__":
test_parse_links_with_anchor()
test_parse_links_alias_dropped()
test_parse_links_anchor_and_alias()
test_parse_links_predicate_simple()
test_parse_links_predicate_bracketed()
test_parse_links_predicate_bracketed_with_anchor()
test_parse_links_predicate_sticks_to_first()
test_parse_links_ignores_legacy_relation_wrappers()
test_parse_links_multiple_on_one_line()
test_parse_wikilink_line_ranges()
test_parse_markdown_links_are_ignored()
test_parse_links_no_match()
test_parse_links_predicate_with_underscore_and_digits()
test_parse_links_in_file()
test_parse_links_empty_when_no_content()
test_chunk_does_not_split_wikilink_at_boundary()

View file

@ -38,6 +38,28 @@ def make_node(path: str, links: list[tuple[str, str | None]] | None = None) -> F
)
def test_file_node_loads_legacy_predicate_without_persisting_it():
"""Legacy predicates are accepted for compatibility but omitted from new snapshots."""
node = FileNode.model_validate(
{
"path": "a.md",
"st_mtime": 1.0,
"links": [
{
"source_path": "a.md",
"target_path": "b.md",
"target_anchor": "intro",
"predicate": "related",
},
],
},
)
assert node.links == [
FileLink(source_path="a.md", target_path="b.md", target_anchor="intro", predicate="related"),
]
assert "predicate" not in node.model_dump_json()
# Both backends should satisfy the same BaseFileGraph contract.
BACKENDS = [LocalFileGraph, NxFileGraph]

View file

@ -0,0 +1,31 @@
"""Tests for GitHub-style line anchor parsing."""
import pytest
from reme.utils import format_line_anchor, parse_line_anchor
def test_parse_supported_line_anchors():
"""Single lines, continuous ranges, and comma-separated ranges parse."""
assert parse_line_anchor("L9") == [(9, 9)]
assert parse_line_anchor("L9-L10") == [(9, 10)]
assert parse_line_anchor("L9-L10,L15-L20") == [(9, 10), (15, 20)]
def test_parse_merges_overlapping_and_adjacent_ranges():
"""Normalization merges ranges whose covered lines touch."""
ranges = parse_line_anchor("L9-L12,L11-L15,L16")
assert ranges == [(9, 16)]
assert format_line_anchor(ranges) == "L9-L16"
def test_non_line_heading_is_unchanged():
"""Ordinary heading anchors are outside this parser's contract."""
assert parse_line_anchor("Introduction") is None
@pytest.mark.parametrize("anchor", ["L0", "L10-L9", "L9-Lx", "L9,"])
def test_invalid_line_anchor_rejected(anchor):
"""Line-looking anchors fail clearly when malformed or out of range."""
with pytest.raises(ValueError):
parse_line_anchor(anchor)

View file

@ -113,7 +113,7 @@ def test_expand_links_returns_outlinks_and_inlinks_with_meta():
assert len(a_out) == 1
assert a_out[0]["path"] == "B.md"
assert a_out[0]["meta"] == {"name": "B Doc", "description": "beta"}
assert a_out[0]["edges"] == [{"predicate": None, "anchor": None}]
assert a_out[0]["anchors"] == []
assert result["A.md"]["inlinks"] == []
b_in = result["B.md"]["inlinks"]
@ -184,13 +184,13 @@ def test_render_expansion_lines_empty_input_yields_empty_list():
def test_render_expansion_lines_outlinks_only():
"""Single outlink with meta + plain edge renders as 3 lines."""
"""Single outlink with metadata renders without a redundant edge line."""
expansion = {
"outlinks": [
{
"path": "B.md",
"meta": {"name": "B", "description": "beta"},
"edges": [{"predicate": None, "anchor": None}],
"anchors": [],
},
],
"inlinks": [],
@ -199,20 +199,19 @@ def test_render_expansion_lines_outlinks_only():
assert lines == [
" outlinks (1):",
' → B.md name="B" description="beta"',
" via plain",
]
print("✓ test_render_expansion_lines_outlinks_only passed")
def test_render_expansion_lines_inlinks_only_with_predicate_and_anchor():
"""Inlink edge with predicate + anchor renders via descriptor."""
def test_render_expansion_lines_inlinks_only_with_anchor():
"""Inlink anchor renders via descriptor."""
expansion = {
"outlinks": [],
"inlinks": [
{
"path": "src.md",
"meta": {},
"edges": [{"predicate": "references", "anchor": "intro"}],
"anchors": ["intro"],
},
],
}
@ -220,26 +219,26 @@ def test_render_expansion_lines_inlinks_only_with_predicate_and_anchor():
assert lines == [
" inlinks (1):",
" ← src.md (no meta)",
" via predicate=references, anchor=#intro",
" via anchor=#intro",
]
print("✓ test_render_expansion_lines_inlinks_only_with_predicate_and_anchor passed")
print("✓ test_render_expansion_lines_inlinks_only_with_anchor passed")
def test_render_expansion_lines_both_directions_in_order():
"""outlinks block precedes inlinks block."""
expansion = {
"outlinks": [
{"path": "out.md", "meta": {"name": "Out"}, "edges": [{"predicate": None, "anchor": None}]},
{"path": "out.md", "meta": {"name": "Out"}, "anchors": []},
],
"inlinks": [
{"path": "in.md", "meta": {"description": "incoming"}, "edges": [{"predicate": None, "anchor": None}]},
{"path": "in.md", "meta": {"description": "incoming"}, "anchors": []},
],
}
lines = render_expansion_lines(expansion)
assert lines[0] == " outlinks (1):"
assert lines[3] == " inlinks (1):"
assert lines[2] == " inlinks (1):"
assert lines[1].lstrip().startswith("")
assert lines[4].lstrip().startswith("")
assert lines[3].lstrip().startswith("")
print("✓ test_render_expansion_lines_both_directions_in_order passed")
@ -250,5 +249,5 @@ if __name__ == "__main__":
test_expand_links_node_without_meta_returns_empty_meta_dict()
test_render_expansion_lines_empty_input_yields_empty_list()
test_render_expansion_lines_outlinks_only()
test_render_expansion_lines_inlinks_only_with_predicate_and_anchor()
test_render_expansion_lines_inlinks_only_with_anchor()
test_render_expansion_lines_both_directions_in_order()

View file

@ -284,9 +284,9 @@ def test_parse_links_literal_targets():
path = _write_md(tmp, "note.md", body)
chunker = MarkdownFileChunker()
node, _ = await chunker.chunk(path)
triples = {(link.target_path, link.target_anchor, link.predicate) for link in node.links}
assert ("topics/Alice.md", None, None) in triples
assert ("topics/Bob.md", "sec", None) in triples
pairs = {(link.target_path, link.target_anchor) for link in node.links}
assert ("topics/Alice.md", None) in pairs
assert ("topics/Bob.md", "sec") in pairs
# source_path always equals the node's own path
for link in node.links:
assert link.source_path == node.path
@ -317,8 +317,8 @@ def test_parse_links_short_and_no_ext_kept_literally():
asyncio.run(run())
def test_parse_links_predicate_inline_and_line():
"""Both `pred:: [[X]]` (line-level) and `[pred:: [[X]]]` (inline) propagate predicate."""
def test_parse_links_legacy_relation_text_is_ignored():
"""Old relation wrappers do not prevent their inner wikilinks from parsing."""
async def run():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
@ -326,16 +326,14 @@ def test_parse_links_predicate_inline_and_line():
path = _write_md(tmp, "note.md", body)
chunker = MarkdownFileChunker()
node, _ = await chunker.chunk(path)
pairs = {(link.target_path, link.predicate) for link in node.links}
assert ("A.md", "extends") in pairs
assert ("B.md", "concerns") in pairs
print("✓ test_parse_links_predicate_inline_and_line passed")
assert {link.target_path for link in node.links} == {"A.md", "B.md"}
print("✓ test_parse_links_legacy_relation_text_is_ignored passed")
asyncio.run(run())
def test_parse_links_deduped():
"""Repeated wikilinks with the same (target, predicate, anchor) emit one FileLink."""
"""Repeated wikilinks with the same target and anchor emit one FileLink."""
async def run():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
@ -616,7 +614,7 @@ if __name__ == "__main__":
test_parse_chunk_ids_match_node_chunk_ids()
test_parse_links_literal_targets()
test_parse_links_short_and_no_ext_kept_literally()
test_parse_links_predicate_inline_and_line()
test_parse_links_legacy_relation_text_is_ignored()
test_parse_links_deduped()
test_parse_min_chunk_byte_size_clamped()
test_parse_embed_toc_prefixes_chunk_text()

View file

@ -141,6 +141,28 @@ def test_read_with_neighbors_injects_block_and_metadata():
_run(run())
def test_read_with_neighbors_uses_posix_nested_path():
"""Nested graph keys use POSIX separators on every platform."""
async def run():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _store_with(
{
"notes/A.md": {"body": "See [[notes/B.md]].", "name": "A Doc"},
"notes/B.md": {"body": "End node.", "name": "B Doc"},
},
)
resp = await _read(store, step_kwargs={"with_neighbors": True}, path="notes/A.md")
expansion = resp.metadata.get("link_expansion")
assert expansion is not None
assert "notes/A.md" in expansion
assert expansion["notes/A.md"]["outlinks"][0]["path"] == "notes/B.md"
await store.close()
_run(run())
# -- on + zero neighbors -----------------------------------------------------

View file

@ -1,4 +1,4 @@
"""Tests for the wikilink helpers in ``reme.utils.wikilink_handler``.
"""Tests for wikilink helpers in ``reme.utils.wikilink_handler``.
Two pure async helpers used by file_move / file_delete:
@ -196,8 +196,8 @@ def test_retarget_image_marker_preserved():
asyncio.run(run())
def test_retarget_dataview_predicate_preserved():
"""Line-level + inline-bracketed Dataview predicates pass through outside ``[[..]]``."""
def test_retarget_preserves_surrounding_relation_text():
"""Retargeting changes only the inner wikilink target."""
async def run():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
@ -212,11 +212,33 @@ def test_retarget_dataview_predicate_preserved():
assert "colleague:: [[people/Alice.md]]" in body
assert "[负责:: [[people/Alice.md]]]" in body
await store.close()
print("✓ test_retarget_dataview_predicate_preserved passed")
print("✓ test_retarget_preserves_surrounding_relation_text passed")
asyncio.run(run())
def test_markdown_links_are_ignored():
"""Only double-bracket wikilinks create graph edges or get rewritten."""
markdown_links = (
"[label](../wiki/example.md) "
"[label](../wiki/example.md#section) "
"[label](../wiki/example.md#L9-L10,L15-L20)"
)
original = f"{markdown_links} [[wiki/example.md#L9]]"
rewritten, count = WikilinkHandler.scan_and_rewrite(
original,
old="wiki/example.md",
new="archive/example.md",
)
assert [(link.target_path, link.target_anchor) for link in WikilinkHandler.extract_links(original, "note.md")] == [
("wiki/example.md", "L9"),
]
assert count == 1
assert rewritten == f"{markdown_links} [[archive/example.md#L9]]"
def test_retarget_multiple_files_aggregate_counts():
"""links_changed sums across files; by_file lists per-file counts."""
@ -387,7 +409,8 @@ if __name__ == "__main__":
test_retarget_alias_preserved()
test_retarget_anchor_and_alias_together()
test_retarget_image_marker_preserved()
test_retarget_dataview_predicate_preserved()
test_retarget_preserves_surrounding_relation_text()
test_markdown_links_are_ignored()
test_retarget_multiple_files_aggregate_counts()
test_retarget_dry_run_does_not_write()
test_retarget_scope_limits_sweep()