lorewiki 0.2.4__tar.gz → 0.2.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lorewiki-0.2.4 → lorewiki-0.2.6}/PKG-INFO +1 -1
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/__init__.py +1 -1
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/cli/__init__.py +1 -0
- lorewiki-0.2.6/lorewiki/cli/install_cmd.py +98 -0
- lorewiki-0.2.6/lorewiki/data/skill_template/SKILL.md +552 -0
- lorewiki-0.2.6/lorewiki/utils/skill_installer.py +391 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/pyproject.toml +9 -2
- {lorewiki-0.2.4 → lorewiki-0.2.6}/.gitignore +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/LICENSE +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/README.md +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/__main__.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/cli/add.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/cli/apps.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/cli/commands.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/cli/config_cmds.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/cli/helpers.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/cli/topic_cmds.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/config.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/db/__init__.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/db/connection.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/db/models.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/db/schema.sql +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/indexer/__init__.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/indexer/chunker.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/indexer/cleaning.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/indexer/indexer.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/indexer/parser.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/llm/__init__.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/llm/client.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/llm/generator.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/py.typed +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/retriever/__init__.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/retriever/base.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/retriever/bm25.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/retriever/fusion.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/retriever/hierarchy.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/retriever/search.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/retriever/vector.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/topic.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/utils/__init__.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/utils/logger.py +0 -0
- {lorewiki-0.2.4 → lorewiki-0.2.6}/lorewiki/utils/topic_shared.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lorewiki
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
4
4
|
Summary: Local-first knowledge base for LLM-assisted coding, with hybrid retrieval (BM25 + hierarchy + optional vector) over SQLite FTS5.
|
|
5
5
|
Project-URL: Documentation, https://github.com/JochenYang/Lore-wiki
|
|
6
6
|
Project-URL: Source, https://github.com/JochenYang/Lore-wiki
|
|
@@ -17,6 +17,7 @@ from lorewiki.cli import (
|
|
|
17
17
|
commands, # noqa: F401 (registers init / index / …)
|
|
18
18
|
config_cmds, # noqa: F401 (registers config list / get / set)
|
|
19
19
|
helpers, # noqa: F401 (side-effect: re-export symbols)
|
|
20
|
+
install_cmd, # noqa: F401 (registers `lorewiki install`)
|
|
20
21
|
topic_cmds, # noqa: F401 (registers topic …)
|
|
21
22
|
)
|
|
22
23
|
from lorewiki.cli.apps import app # re-exported for the entry point
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""``lorewiki install`` — install / uninstall / inspect the agent skill.
|
|
2
|
+
|
|
3
|
+
This is the wheel-side counterpart of the source-tree
|
|
4
|
+
``skills/install.py``. A user who installed ``lorewiki`` from PyPI
|
|
5
|
+
runs ``lorewiki install`` (no need to clone the repository); a
|
|
6
|
+
developer who already has the source tree checked out can still
|
|
7
|
+
use ``python skills/install.py`` — the two paths share the same
|
|
8
|
+
TOOL catalog, prompt grammar, and install semantics.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Annotated
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from lorewiki.cli.apps import app
|
|
17
|
+
from lorewiki.utils.skill_installer import run as _run
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command()
|
|
21
|
+
def install(
|
|
22
|
+
tool: Annotated[
|
|
23
|
+
str | None,
|
|
24
|
+
typer.Option(
|
|
25
|
+
"--tool",
|
|
26
|
+
help=(
|
|
27
|
+
"Comma-separated tool ids to install into (e.g. "
|
|
28
|
+
"'opencode,claude,codex'). If omitted and --all is also "
|
|
29
|
+
"omitted, an interactive multi-select prompt is shown."
|
|
30
|
+
),
|
|
31
|
+
),
|
|
32
|
+
] = None,
|
|
33
|
+
all_: Annotated[
|
|
34
|
+
bool,
|
|
35
|
+
typer.Option(
|
|
36
|
+
"--all",
|
|
37
|
+
help=(
|
|
38
|
+
"Install into every detected tool without prompting. "
|
|
39
|
+
"A tool is 'detected' when its config root "
|
|
40
|
+
"(e.g. ~/.config/opencode) already exists on this machine."
|
|
41
|
+
),
|
|
42
|
+
),
|
|
43
|
+
] = False,
|
|
44
|
+
uninstall: Annotated[
|
|
45
|
+
bool,
|
|
46
|
+
typer.Option(
|
|
47
|
+
"--uninstall",
|
|
48
|
+
help="Remove the skill from the target tool directories instead of installing.",
|
|
49
|
+
),
|
|
50
|
+
] = False,
|
|
51
|
+
force: Annotated[
|
|
52
|
+
bool,
|
|
53
|
+
typer.Option(
|
|
54
|
+
"--force",
|
|
55
|
+
help="Overwrite an existing install at the target path.",
|
|
56
|
+
),
|
|
57
|
+
] = False,
|
|
58
|
+
status: Annotated[
|
|
59
|
+
bool,
|
|
60
|
+
typer.Option(
|
|
61
|
+
"--status",
|
|
62
|
+
help="Print where the skill is currently installed and exit.",
|
|
63
|
+
),
|
|
64
|
+
] = False,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Install the LoreWiki agent skill into one or more AI tool directories.
|
|
67
|
+
|
|
68
|
+
By default the command prints a numbered list of detected tools
|
|
69
|
+
(the AI coding tools whose config root is already on this
|
|
70
|
+
machine) and asks you to pick. The prompt accepts:
|
|
71
|
+
|
|
72
|
+
\b
|
|
73
|
+
- a single index (``3``)
|
|
74
|
+
- multiple indices (``1,3,5`` or ``1 3 5``)
|
|
75
|
+
- a range (``2-4``)
|
|
76
|
+
- mixed forms (``1,3-5,6``)
|
|
77
|
+
- ``a`` to install into every detected tool
|
|
78
|
+
- ``q`` (or empty) to quit without installing
|
|
79
|
+
|
|
80
|
+
Pass ``--tool`` or ``--all`` to skip the prompt. ``--uninstall``
|
|
81
|
+
reverses the operation; ``--force`` overwrites an existing
|
|
82
|
+
install at the target path.
|
|
83
|
+
"""
|
|
84
|
+
tool_ids = (
|
|
85
|
+
[s.strip() for s in tool.split(",") if s.strip()]
|
|
86
|
+
if tool is not None
|
|
87
|
+
else None
|
|
88
|
+
)
|
|
89
|
+
rc = _run(
|
|
90
|
+
tool_ids=tool_ids,
|
|
91
|
+
install_all=all_,
|
|
92
|
+
uninstall=uninstall,
|
|
93
|
+
force=force,
|
|
94
|
+
show_status=status,
|
|
95
|
+
)
|
|
96
|
+
if rc:
|
|
97
|
+
raise typer.Exit(code=rc)
|
|
98
|
+
|
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: lorewiki
|
|
3
|
+
description: "Local-first Markdown knowledge base with hybrid retrieval (BM25 + heading-hierarchy + RRF) and optional LLM answer generation. Use when the user wants to look something up in an internal wiki, ask a question grounded in their team docs, browse the hierarchy of stored notes, or persist a learning / decision / postmortem into a queryable store. Triggers on the natural-language intent to recall or save knowledge — the LLM should match by meaning, not by exact keyword. CLI is one shell call per command with JSON output by default for `search` / `show` / `tree`."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# lorewiki
|
|
7
|
+
|
|
8
|
+
Local-first Markdown knowledge base. Index a directory of `.md` files into
|
|
9
|
+
SQLite + FTS5, then retrieve / answer / browse / author via the `lorewiki` CLI.
|
|
10
|
+
|
|
11
|
+
**Why this skill**: every command is one shell call, output is structured
|
|
12
|
+
JSON by default for `search`/`show`/`tree` (no flag needed), no daemon
|
|
13
|
+
to keep alive, no client config, no server to run. Works inside
|
|
14
|
+
opencode / Codex / Aider / cron / CI scripts.
|
|
15
|
+
|
|
16
|
+
**Output convention** (v0.2.0+):
|
|
17
|
+
|
|
18
|
+
| Command | Default | Human-readable flag |
|
|
19
|
+
|----------------|--------------------------|----------------------------|
|
|
20
|
+
| `search` | JSON (for agents) | `--human` (Rich Table) |
|
|
21
|
+
| `tree` | Rich Tree | (always human) |
|
|
22
|
+
| `show` | Cleaned markdown body | `--raw` (on-disk verbatim) |
|
|
23
|
+
| `ask` | Markdown-rendered answer | `--raw` (JSON) |
|
|
24
|
+
| `topic list` | Rich panel | `--raw` (JSON) |
|
|
25
|
+
| `add` | Rich panel | `--raw` (JSON) |
|
|
26
|
+
|
|
27
|
+
## When To Use
|
|
28
|
+
|
|
29
|
+
Invoke this skill whenever the user wants to:
|
|
30
|
+
|
|
31
|
+
- **Read** — search the wiki (`lorewiki search`), ask a question
|
|
32
|
+
(`lorewiki ask`), browse the hierarchy (`lorewiki tree`), or dump a
|
|
33
|
+
single doc (`lorewiki show`).
|
|
34
|
+
- **Write** — author a single note end-to-end via `lorewiki add`. The
|
|
35
|
+
command takes body via `--body` / `--file` / stdin, slugifies the
|
|
36
|
+
title into a filename, writes a Markdown file with frontmatter,
|
|
37
|
+
and triggers an incremental `build_index` so the new doc is
|
|
38
|
+
immediately retrievable. Use this whenever the user wants to
|
|
39
|
+
persist a learning, decision, postmortem, or any small chunk of
|
|
40
|
+
knowledge into the wiki.
|
|
41
|
+
- **Index** — refresh the SQLite index from disk after manual edits
|
|
42
|
+
to .md files (`lorewiki index`). `add` runs this for you.
|
|
43
|
+
- **Inspect** — `lorewiki status` shows chunk / doc / last-indexed
|
|
44
|
+
counts; `lorewiki topic list` enumerates topics.
|
|
45
|
+
|
|
46
|
+
Trigger words: `wiki`, `knowledge base`, `lorewiki`, `internal docs`,
|
|
47
|
+
`runbook`, `postmortem`, `team docs` — and the obvious
|
|
48
|
+
language-localised equivalents in whatever language the user is
|
|
49
|
+
using. The LLM should match by *intent* (recall / save a note /
|
|
50
|
+
browse the structure), not by exact keyword match.
|
|
51
|
+
|
|
52
|
+
## Prerequisites
|
|
53
|
+
|
|
54
|
+
The `lorewiki` CLI must be on the user's PATH. Check with:
|
|
55
|
+
|
|
56
|
+
```powershell
|
|
57
|
+
lorewiki --version # expect: LoreWiki 0.1.0 (or newer)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
If missing:
|
|
61
|
+
|
|
62
|
+
```powershell
|
|
63
|
+
# From the source repo (editable install — easiest for active development)
|
|
64
|
+
pip install -e D:/codes/Lorewiki
|
|
65
|
+
|
|
66
|
+
# Or, once published, via pipx for an isolated global install
|
|
67
|
+
pipx install lorewiki
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The user must also have a wiki directory with at least `<wiki>/.lorewiki/config.toml`.
|
|
71
|
+
Initialise one if absent: `lorewiki init --path <PATH>`.
|
|
72
|
+
|
|
73
|
+
## Path Handling Convention
|
|
74
|
+
|
|
75
|
+
Every `lorewiki` invocation takes an optional `--path <WIKI_ROOT>`. The
|
|
76
|
+
agent should pick a wiki using **this exact priority chain**:
|
|
77
|
+
|
|
78
|
+
1. **Explicit value the user typed** in their message — use it verbatim.
|
|
79
|
+
2. **Environment variable** `LOREWIKI_WIKI_PATH` — `$env:LOREWIKI_WIKI_PATH`
|
|
80
|
+
on Windows, `$LOREWIKI_WIKI_PATH` on macOS/Linux. Power users set this so
|
|
81
|
+
they never have to specify `--path`.
|
|
82
|
+
3. **`.lorewiki/config.toml` in cwd or any ancestor** of cwd — fastest probe:
|
|
83
|
+
```powershell
|
|
84
|
+
Get-Item .\.lorewiki\config.toml -ErrorAction SilentlyContinue
|
|
85
|
+
```
|
|
86
|
+
4. **Bounded filesystem scan** — when none of the above resolved, look in
|
|
87
|
+
common roots with a **shallow** depth (don't scan the entire D:\ or $HOME):
|
|
88
|
+
```powershell
|
|
89
|
+
Get-ChildItem -Path D:\codes, $env:USERPROFILE\Documents `
|
|
90
|
+
-Filter ".lorewiki" -Recurse -Directory -Depth 3 `
|
|
91
|
+
-ErrorAction SilentlyContinue | Select-Object FullName
|
|
92
|
+
```
|
|
93
|
+
5. **Multiple candidates** — call `lorewiki status --path <CAND>` on each;
|
|
94
|
+
pick the one with the highest `Chunks` count (it's the populated wiki).
|
|
95
|
+
Document the choice once, cache it for the rest of the conversation.
|
|
96
|
+
6. **No candidates at all** — ask the user which wiki to use, or offer
|
|
97
|
+
`lorewiki init --path <PATH>` to bootstrap a new one.
|
|
98
|
+
|
|
99
|
+
**Reproducibility rule**: always pass `--path "<ABS_PATH>"` in the actual
|
|
100
|
+
shell call so the command is portable; never rely on cwd at the call site.
|
|
101
|
+
On Windows use forward slashes in arguments to dodge quoting issues:
|
|
102
|
+
`--path D:/codes/Lorewiki/example_wiki`.
|
|
103
|
+
|
|
104
|
+
## Topics (second-brain vaults)
|
|
105
|
+
|
|
106
|
+
By default `lorewiki search` queries whichever topic is active. Topics
|
|
107
|
+
are isolated vaults under `~/lorewiki/topics/<name>/`, shared across
|
|
108
|
+
every project the user works in. Discovery flow when the user asks
|
|
109
|
+
"look up X in my react notes" or "search the cocos wiki":
|
|
110
|
+
|
|
111
|
+
1. `lorewiki topic list --raw` — enumerate. The active one is starred
|
|
112
|
+
(`*`); its name is the value of `~/lorewiki/current`.
|
|
113
|
+
2. If no topic is active or the user wants a different one, run
|
|
114
|
+
`lorewiki topic use <name>` first.
|
|
115
|
+
3. If the user mentions a topic that doesn't exist yet, follow the
|
|
116
|
+
**Naming Protocol** below to pick a name and confirm with the user.
|
|
117
|
+
4. The active topic's wiki root doubles as an Obsidian / Logseq
|
|
118
|
+
vault — the user may prefer to edit Markdown directly and just
|
|
119
|
+
re-run `lorewiki index` (which is incremental).
|
|
120
|
+
|
|
121
|
+
### Naming Protocol
|
|
122
|
+
|
|
123
|
+
When the user says "make me a wiki for X" and X is a free-form
|
|
124
|
+
description (e.g. "react hooks learning", "wechat miniprogram dev"),
|
|
125
|
+
**do not** silently invent a name. Follow this protocol:
|
|
126
|
+
|
|
127
|
+
1. `lorewiki topic list --raw` — check for name collisions and pick
|
|
128
|
+
the active topic (if any) for context.
|
|
129
|
+
2. `lorewiki topic suggest "<X description>"` — get 1-4 candidate
|
|
130
|
+
slugs. The algorithm is rule-based (slugify + stopword removal);
|
|
131
|
+
for CJK-only descriptions it returns nothing.
|
|
132
|
+
3. **Show the user the candidates and ask which to use.** Don't
|
|
133
|
+
auto-pick. Example reply:
|
|
134
|
+
> I can call this `wechat-mp`, `wechat-miniprogram`, or `mp`. Which
|
|
135
|
+
> do you prefer? (Or pick your own name — rules: lowercase,
|
|
136
|
+
> digits, hyphens, 1-64 chars.)
|
|
137
|
+
4. `lorewiki topic create <chosen> [--source <path-to-existing-md>]`.
|
|
138
|
+
Default mode copies; `--link` symlinks instead.
|
|
139
|
+
5. If the user later dislikes the name, `lorewiki topic rename
|
|
140
|
+
<old> <new>` renames in place (the index and config move with
|
|
141
|
+
it; the active pointer is updated if applicable).
|
|
142
|
+
|
|
143
|
+
`topic suggest` is **English-friendly** by design. For CJK
|
|
144
|
+
descriptions, the command exits with code 1 and prints a panel that
|
|
145
|
+
tells the user to name the topic by hand. The agent should fall
|
|
146
|
+
back to asking the user explicitly in that case.
|
|
147
|
+
|
|
148
|
+
**Path resolution priority** (later wins):
|
|
149
|
+
1. `--topic` flag
|
|
150
|
+
2. `LOREWIKI_TOPIC` env var
|
|
151
|
+
3. `~/lorewiki/current` file (set by `lorewiki topic use`)
|
|
152
|
+
4. `--path` flag (legacy per-wiki mode)
|
|
153
|
+
5. cwd `.lorewiki/config.toml` (legacy per-project mode)
|
|
154
|
+
|
|
155
|
+
Topic names: lowercase ASCII, digits, hyphens, 1-64 chars, no
|
|
156
|
+
leading/trailing hyphens. The `lorewiki topic create` command will
|
|
157
|
+
reject anything else with a clear error.
|
|
158
|
+
|
|
159
|
+
**Important**: a `~/.lorewiki/topics/<name>/.lorewiki/index.db` only
|
|
160
|
+
exists *after* `lorewiki index` has been run. If the user asks to
|
|
161
|
+
search a brand-new topic, expect the `No index found` panel — point
|
|
162
|
+
them at `lorewiki topic use <name> && lorewiki index`.
|
|
163
|
+
|
|
164
|
+
## Core Workflows
|
|
165
|
+
|
|
166
|
+
### 1. Retrieve and cite (most common)
|
|
167
|
+
|
|
168
|
+
User asks something likely covered by team docs ⇒ search, then ground the
|
|
169
|
+
answer in the returned chunks with file-path citations.
|
|
170
|
+
|
|
171
|
+
```powershell
|
|
172
|
+
lorewiki search "<question or keywords>" `
|
|
173
|
+
--path "<WIKI>" --mode mix --top-k 5
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**No `--raw` flag** for `search` — JSON is the default. Pass `--human` if
|
|
177
|
+
you actually want a Rich Table for terminal eyeballing (rare for agents).
|
|
178
|
+
|
|
179
|
+
Returned JSON shape (parse it; don't grep the prettified panel):
|
|
180
|
+
|
|
181
|
+
```json
|
|
182
|
+
[
|
|
183
|
+
{
|
|
184
|
+
"chunk_id": "api/user/auth.md#0",
|
|
185
|
+
"doc_path": "api/user/auth.md",
|
|
186
|
+
"title": "Authentication API",
|
|
187
|
+
"heading_path": "Auth API > Overview",
|
|
188
|
+
"module": "api/user",
|
|
189
|
+
"snippet": "...",
|
|
190
|
+
"score": 0.029,
|
|
191
|
+
"retriever": "mix"
|
|
192
|
+
},
|
|
193
|
+
...
|
|
194
|
+
]
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The `snippet` field contains the **full chunk body** (anchor markup, scraper
|
|
198
|
+
boilerplate, and the translation footer are stripped at index time — see
|
|
199
|
+
`lorewiki.indexer.cleaning` for the rules). The `title` has no leading `#`
|
|
200
|
+
and `heading_path` is ` > `-joined segments with anchors removed. The
|
|
201
|
+
breadcrumb prefix that the chunker adds for FTS recall is also stripped
|
|
202
|
+
from the snippet (it's already in `heading_path`).
|
|
203
|
+
|
|
204
|
+
Then compose an answer that:
|
|
205
|
+
- quotes the relevant snippets,
|
|
206
|
+
- cites each fact with its `doc_path` (and optionally `heading_path`),
|
|
207
|
+
- does NOT fabricate beyond what the snippets say.
|
|
208
|
+
|
|
209
|
+
### 2. LLM-assisted answer
|
|
210
|
+
|
|
211
|
+
When the user explicitly wants a synthesised answer (not just chunks),
|
|
212
|
+
use `ask`. It already does retrieval + prompt assembly + LLM call:
|
|
213
|
+
|
|
214
|
+
```powershell
|
|
215
|
+
lorewiki ask "<question>" --path "<WIKI>" --top-k 5 --raw
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`ask` defaults to a Markdown-rendered panel (human-friendly); add
|
|
219
|
+
`--raw` to get the JSON shape with `answer`, `used_llm`, `degraded_reason`,
|
|
220
|
+
and `hits`. If `used_llm == false`, hand the `answer` text straight back
|
|
221
|
+
to the user — no need for a second tool call.
|
|
222
|
+
|
|
223
|
+
### 3. Discover the structure (before broad questions)
|
|
224
|
+
|
|
225
|
+
If the user's request is broad ("what's in the wiki?", "what modules do we
|
|
226
|
+
have?"), use the tree view:
|
|
227
|
+
|
|
228
|
+
```powershell
|
|
229
|
+
lorewiki tree # full hierarchy
|
|
230
|
+
lorewiki tree api/share --depth 3 # sub-tree with depth limit
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
For "show me everything under module X" style queries, search in hierarchy
|
|
234
|
+
mode (returns chunks grouped by the matched tree node):
|
|
235
|
+
|
|
236
|
+
```powershell
|
|
237
|
+
lorewiki search "<module name>" --path "<WIKI>" --mode hierarchy --top-k 10
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### 4. Write a new note to the wiki (knowledge persistence)
|
|
241
|
+
|
|
242
|
+
Follow this template strictly so the indexer picks up the right metadata:
|
|
243
|
+
|
|
244
|
+
```markdown
|
|
245
|
+
---
|
|
246
|
+
title: "Decision: switch to Redis Streams for event bus"
|
|
247
|
+
module: decisions
|
|
248
|
+
tags: [decision, infra, redis]
|
|
249
|
+
owner: platform-team
|
|
250
|
+
last_review: 2026-06-10
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
# Decision: switch to Redis Streams
|
|
254
|
+
|
|
255
|
+
## Context
|
|
256
|
+
<why we are deciding this>
|
|
257
|
+
|
|
258
|
+
## Decision
|
|
259
|
+
<what we will do>
|
|
260
|
+
|
|
261
|
+
## Consequences
|
|
262
|
+
<trade-offs + follow-ups>
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
**Frontmatter rules** (the indexer reads these fields):
|
|
266
|
+
|
|
267
|
+
| Field | Required | Notes |
|
|
268
|
+
|---------------|-----------------|----------------------------------------------------------------------------------|
|
|
269
|
+
| `title` | best-effort | Frontmatter wins, else first H1, else the filename. Always set it explicitly. |
|
|
270
|
+
| `module` | best-effort | Logical hierarchy path (e.g. `decisions`, `api/user`). See §Path semantics below. |
|
|
271
|
+
| `tags` | optional | Free-form list; aids hierarchy search. |
|
|
272
|
+
| `owner` | optional | Team or person responsible. |
|
|
273
|
+
| `last_review` | optional | ISO date; helps with staleness audits. |
|
|
274
|
+
|
|
275
|
+
Place it under the right module directory, then re-index:
|
|
276
|
+
|
|
277
|
+
```powershell
|
|
278
|
+
# 1) Write the file (use the Write/Edit tool — never `Out-File`, BOM issues)
|
|
279
|
+
# Target path example: D:/codes/Lorewiki/example_wiki/decisions/redis-streams.md
|
|
280
|
+
|
|
281
|
+
# 2) Re-index (incremental — only changed files are re-processed)
|
|
282
|
+
lorewiki index --path "<WIKI>"
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Verify by searching for a distinctive phrase from the new doc:
|
|
286
|
+
|
|
287
|
+
```powershell
|
|
288
|
+
lorewiki search "Redis Streams decision" --path "<WIKI>" --mode mix --top-k 3
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
#### Path semantics (read this once, it shapes the whole vault)
|
|
292
|
+
|
|
293
|
+
The `module:` field is a **logical category**, not a physical file path.
|
|
294
|
+
The two don't have to match (the parser doesn't enforce it), but
|
|
295
|
+
**keep them aligned** so the hierarchy tree is useful for browsing:
|
|
296
|
+
|
|
297
|
+
- **Aligned** (recommended): file at `api/user/auth.md` has
|
|
298
|
+
`module: api/user`. Walking the hierarchy gives you
|
|
299
|
+
`api/ → api/user/ → docs` and the file shows up under
|
|
300
|
+
`api/user` in `lorewiki status`.
|
|
301
|
+
- **Mis-aligned** (allowed, but degrades the UI): file at
|
|
302
|
+
`patterns/rate-limit.md` with `module: patterns` — the
|
|
303
|
+
hierarchy only shows one level (`patterns`), but the file still
|
|
304
|
+
indexes and searches normally.
|
|
305
|
+
|
|
306
|
+
**Rule of thumb**: when in doubt, set `module` to the directory the
|
|
307
|
+
file is in (or a sensible parent).
|
|
308
|
+
|
|
309
|
+
#### Quality checklist for scraped / external content
|
|
310
|
+
|
|
311
|
+
If you are **fetching** documentation and writing it into the vault
|
|
312
|
+
(common workflow in the user's AI tools), the indexer will accept
|
|
313
|
+
anything but search quality collapses on certain patterns. **Don't**:
|
|
314
|
+
|
|
315
|
+
| Anti-pattern | Why it breaks | What to do instead |
|
|
316
|
+
|-----------------------------------------------------------|--------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------|
|
|
317
|
+
| Filename ending in `.html` (e.g. `wx.arrayBufferToBase64.html`) | Obsidian / Logseq render the file as raw HTML source; internal links fail to resolve | Strip the `.html` extension. The file is markdown, not HTML. |
|
|
318
|
+
| `_index.md` (underscore-prefixed) | LoreWiki has no special handling for `_index.md`; you'll get an empty `title: ""` and the indexer falls back to the first H1, which is often a quote or nav block | Use plain `index.md` (or a topic-specific name like `api-overview.md`). |
|
|
319
|
+
| `title: ""` (empty in frontmatter) | The indexer falls back to the first H1 — if the first H1 is a quote, a nav link, or a heading with special characters, search titles become ugly | Always fill `title` with a short, human-readable summary. |
|
|
320
|
+
| Internal links pointing to `.html` files | `(/api/base/wx.env.html)` — Obsidian and the indexer can't follow them | Strip `.html` from the link target, or convert to the equivalent `.md` path. |
|
|
321
|
+
| Module path with spaces / capital letters | The hierarchy tree treats it as a distinct node; search ranks it as a separate entity | Use kebab-case lowercase segments: `api/user`, `patterns/rate-limit`, `decisions/redis`. |
|
|
322
|
+
| Scrape artefact files in the vault root (e.g. `manifest.json`, `scrape.log`) | These pollute the Obsidian vault view; they don't index but they confuse the user | Put scrape artefacts in a separate cache directory (`~/.lorewiki/.scrape-cache/<topic>/`), not in the vault |
|
|
323
|
+
|
|
324
|
+
After the write, run `lorewiki index --path "<WIKI>"` and spot-check
|
|
325
|
+
with `lorewiki status --path "<WIKI>"` — the chunk count and
|
|
326
|
+
hierarchy depth will tell you if the structure is sensible.
|
|
327
|
+
|
|
328
|
+
### 5. Fresh wiki bootstrap
|
|
329
|
+
|
|
330
|
+
```powershell
|
|
331
|
+
lorewiki init --path "<NEW_WIKI_DIR>"
|
|
332
|
+
# Author Markdown files under <NEW_WIKI_DIR>/...
|
|
333
|
+
lorewiki index --path "<NEW_WIKI_DIR>" --rebuild
|
|
334
|
+
lorewiki status --path "<NEW_WIKI_DIR>"
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### 6. Configuration inspection / change
|
|
338
|
+
|
|
339
|
+
> **Single source of truth**: all lorewiki config lives in **one**
|
|
340
|
+
> file: `~/.lorewiki/config.toml`. We **no longer** drop a
|
|
341
|
+
> `config.toml` into each topic root (it polluted the vault view
|
|
342
|
+
> in Obsidian / Logseq). If you need per-topic overrides, edit the
|
|
343
|
+
> global file with a `[topics.<name>]` section header.
|
|
344
|
+
|
|
345
|
+
There is **no config.toml to discover** until the user creates one.
|
|
346
|
+
The recommended way to bootstrap it:
|
|
347
|
+
|
|
348
|
+
```powershell
|
|
349
|
+
# Option A — interactive, recommended for humans
|
|
350
|
+
lorewiki config set llm.enabled true
|
|
351
|
+
lorewiki config set llm.backend '"openai"'
|
|
352
|
+
lorewiki config set llm.openai_api_key '"sk-..."'
|
|
353
|
+
lorewiki config set llm.openai_model '"gpt-4o-mini"'
|
|
354
|
+
# (each call writes ~/.lorewiki/config.toml; subsequent `lorewiki config list`
|
|
355
|
+
# shows the merged result)
|
|
356
|
+
|
|
357
|
+
# Option B — write the file directly
|
|
358
|
+
# Recommended for headless / CI use, and the only way to set
|
|
359
|
+
# OpenAI-compatible endpoints that point at OpenRouter / vLLM / etc.
|
|
360
|
+
notepad ~/.lorewiki/config.toml # or your editor of choice
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
A fully-populated `~/.lorewiki/config.toml` (every supported key
|
|
364
|
+
is shown, comments mark the lines you'll most often change):
|
|
365
|
+
|
|
366
|
+
```toml
|
|
367
|
+
# ~/.lorewiki/config.toml — LoreWiki's single config file.
|
|
368
|
+
# Anything you don't set here falls back to the in-code default.
|
|
369
|
+
|
|
370
|
+
# ----- Retrieval -----
|
|
371
|
+
retrieval_mode = "mix" # mix | bm25 | hierarchy | vector
|
|
372
|
+
mix_weights_bm25 = 1.0
|
|
373
|
+
mix_weights_hierarchy = 0.8
|
|
374
|
+
mix_weights_vector = 0.5
|
|
375
|
+
rrf_k = 60 # RRF smoothing constant
|
|
376
|
+
chunk_max_tokens = 800
|
|
377
|
+
chunk_overlap_tokens = 100
|
|
378
|
+
chunk_min_chars = 40
|
|
379
|
+
snippet_chars = 240
|
|
380
|
+
|
|
381
|
+
# ----- LLM (optional) -----
|
|
382
|
+
# Enabled = false by default. When false, `lorewiki ask` falls
|
|
383
|
+
# back to the top-K chunks panel (graceful degradation).
|
|
384
|
+
[llm]
|
|
385
|
+
enabled = false
|
|
386
|
+
|
|
387
|
+
# --- Option A: local Ollama ---
|
|
388
|
+
backend = "ollama"
|
|
389
|
+
ollama_url = "http://localhost:11434"
|
|
390
|
+
ollama_model = "qwen2.5:7b"
|
|
391
|
+
|
|
392
|
+
# --- Option B: OpenAI-compatible (any provider speaking ---
|
|
393
|
+
# --- POST /v1/chat/completions) ---
|
|
394
|
+
# backend = "openai"
|
|
395
|
+
# openai_api_key = "sk-or-..." # OpenRouter key, OpenAI key, etc.
|
|
396
|
+
# openai_base_url = "https://openrouter.ai/api/v1" # <- any vLLM-compatible endpoint
|
|
397
|
+
# openai_model = "meta-llama/llama-3.1-8b-instruct:free"
|
|
398
|
+
|
|
399
|
+
# timeout_seconds = 30.0
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
Quick CLI:
|
|
403
|
+
|
|
404
|
+
```powershell
|
|
405
|
+
lorewiki config list # show the resolved config (with defaults)
|
|
406
|
+
lorewiki config get llm.backend # get one key
|
|
407
|
+
lorewiki config set llm.enabled true # set one key (auto-creates the file)
|
|
408
|
+
lorewiki config set llm.openai_base_url '"https://openrouter.ai/api/v1"'
|
|
409
|
+
# Note: when setting a string, quote it as a TOML literal.
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
> **Note on Azure OpenAI**: the Azure endpoint path is
|
|
413
|
+
> `/openai/deployments/<deployment>/chat/completions?api-version=...`
|
|
414
|
+
> and is **not** currently supported. Use OpenRouter or a
|
|
415
|
+
> self-hosted vLLM-compatible endpoint, or wait for phase-7 Azure
|
|
416
|
+
> support (open an issue if you need it sooner).
|
|
417
|
+
|
|
418
|
+
### 7. Write to the wiki — pick the right path
|
|
419
|
+
|
|
420
|
+
There are three honest ways to put content into the wiki. Pick the
|
|
421
|
+
smallest one that does the job — over-engineering hurts:
|
|
422
|
+
|
|
423
|
+
| Scenario | Use |
|
|
424
|
+
|------------------------------------------------------------|-----------------------------------------------------------------------------------------------|
|
|
425
|
+
| **One-off note** (1-3 paragraphs, user just told you) | `lorewiki add --title ... --body ...` — writes + auto-indexes in one step. |
|
|
426
|
+
| **A handful of related notes** (≤ ~20) | Run `lorewiki add` a few times in a loop. Don't write a script. |
|
|
427
|
+
| **Bulk scrape** (tens to thousands of files, e.g. scraping | Either (a) drop the directory under the active topic and run `lorewiki index` (one-shot), |
|
|
428
|
+
| a vendor's docs, importing a git repo, etc.) | or (b) write a short Python script that writes `.md` files with frontmatter and call |
|
|
429
|
+
| | `lorewiki index` once at the end. |
|
|
430
|
+
| **Topic bootstrap** (creating a new isolated vault) | `lorewiki topic create <name> [--source <path>]` — the topic system is built for this. |
|
|
431
|
+
|
|
432
|
+
**Auto-judgment rule for the LLM**: if the user pasted a single
|
|
433
|
+
short note, reach for `lorewiki add`. If the user gave you a list,
|
|
434
|
+
a URL, or a directory to capture, reach for a Python script (or
|
|
435
|
+
`lorewiki topic create --source`). Don't run `lorewiki add` in a
|
|
436
|
+
50-iteration loop — the per-invocation indexing cost adds up. The
|
|
437
|
+
user's language for "store this" / "capture these" / "记一下" /
|
|
438
|
+
"保存这些" / "メモして" / whatever is irrelevant to the rule; the
|
|
439
|
+
trigger is *shape* (paragraph vs list/URL/directory), not language.
|
|
440
|
+
|
|
441
|
+
#### `lorewiki add` quick reference
|
|
442
|
+
|
|
443
|
+
```powershell
|
|
444
|
+
# Inline body
|
|
445
|
+
lorewiki add `
|
|
446
|
+
--title "Python Design" `
|
|
447
|
+
--module patterns `
|
|
448
|
+
--tag python --tag design `
|
|
449
|
+
--body "Some deep details about Python design pattern."
|
|
450
|
+
|
|
451
|
+
# Pipe from stdin (no --body)
|
|
452
|
+
echo "# Inferred Title`n`nbody text" | lorewiki add --module notes
|
|
453
|
+
|
|
454
|
+
# Bulk scrape: short Python script
|
|
455
|
+
$urls = @("https://vendor.example.com/docs/a", "https://vendor.example.com/docs/b")
|
|
456
|
+
foreach ($u in $urls) {
|
|
457
|
+
$md = (Invoke-WebRequest $u).Content | ./scrape.ps1
|
|
458
|
+
$slug = ($u -split "/" | Select-Object -Last 1) -replace ".html$", ""
|
|
459
|
+
lorewiki add --title $slug --module scraped --body $md --path $wiki
|
|
460
|
+
}
|
|
461
|
+
lorewiki index # only if add was called many times; usually redundant
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
The file lands at `<wiki>/<module>/<slug>.md`; the slug is derived
|
|
465
|
+
from the title via ASCII-only normalisation (Chinese characters in
|
|
466
|
+
the title are stripped, so "RISC-V 工具链" becomes `risc-v` — see
|
|
467
|
+
[slug caveats](#slug-caveats) below). After a successful write,
|
|
468
|
+
an incremental `build_index` runs so the new doc is immediately
|
|
469
|
+
retrievable. Use `--raw` for machine-readable JSON output,
|
|
470
|
+
`--force` to overwrite an existing file. The path-traversal check
|
|
471
|
+
will reject any `--module` value that resolves outside the wiki
|
|
472
|
+
root.
|
|
473
|
+
|
|
474
|
+
#### Slug caveats
|
|
475
|
+
|
|
476
|
+
The slug is built with `[^a-z0-9]+` → `-`, lowercased, trimmed, then
|
|
477
|
+
capped at 64 chars. **Non-ASCII characters in the title are
|
|
478
|
+
stripped** — so a title like "RISC-V 工具链" produces the slug
|
|
479
|
+
`risc-v` (the CJK portion is gone). The body keeps everything;
|
|
480
|
+
only the filename is ASCII-folded. This affects every non-ASCII
|
|
481
|
+
script uniformly (CJK, Vietnamese, Thai, Korean, Cyrillic, etc.).
|
|
482
|
+
For better filename preservation, the user should pick an
|
|
483
|
+
English title and put the original-language text in `--body`.
|
|
484
|
+
|
|
485
|
+
## Modes (`--mode` flag for search / configured for ask)
|
|
486
|
+
|
|
487
|
+
| Mode | Best for | Notes |
|
|
488
|
+
| --------- | --------------------------------------------------- | ------------------------------------------- |
|
|
489
|
+
| `mix` | Almost everything (default). | RRF-fused BM25 + hierarchy. Highest recall. |
|
|
490
|
+
| `bm25` | Exact-term / English / code-symbol queries. | FTS5 trigram + LIKE fallback for short CJK. |
|
|
491
|
+
| `hierarchy` | "Show me everything under module X" style queries.| Walks the module tree from matched node. |
|
|
492
|
+
| `vector` | Not implemented yet — silently falls back to `mix`. | Reserved for the phase-6 sqlite-vec layer. |
|
|
493
|
+
|
|
494
|
+
## Output Discipline
|
|
495
|
+
|
|
496
|
+
- **`search` already defaults to JSON** — no flag needed. Only `ask`,
|
|
497
|
+
`topic list`, `topic suggest`, and `show` have a `--raw` (for those,
|
|
498
|
+
`--raw` is *opt-in* to switch off the human-friendly default).
|
|
499
|
+
- **Cite by `doc_path`** in your final answer (`[api/user/auth.md]`), not by
|
|
500
|
+
internal chunk IDs.
|
|
501
|
+
- If the wiki has nothing relevant (`hits == []`), say so plainly. Do NOT
|
|
502
|
+
hallucinate content the wiki doesn't contain.
|
|
503
|
+
- For `ask` with `used_llm == false`, the returned `answer` already lists
|
|
504
|
+
the chunks — you can pass it through unchanged.
|
|
505
|
+
- **Auto-judge the write path**: prefer `lorewiki add` for 1-3
|
|
506
|
+
paragraphs the user just told you. Drop a directory + `lorewiki index`
|
|
507
|
+
for bulk. **Never** wrap `add` in a 50-iteration loop when a
|
|
508
|
+
short script + one index call would be cleaner.
|
|
509
|
+
|
|
510
|
+
## Common Pitfalls
|
|
511
|
+
|
|
512
|
+
| Pitfall | Avoidance |
|
|
513
|
+
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
|
514
|
+
| Reaching for `lorewiki add` in a tight loop for bulk ingestion. | Drop the source directory and run `lorewiki index` once. Re-indexing every add is O(N) — a single bulk index is O(1). |
|
|
515
|
+
| Searching with a 1-2 character CJK query and getting "0 hits". | Combine with at least one more char or use `--mode bm25` which falls back to LIKE for short queries. |
|
|
516
|
+
| Forgetting `--path` and assuming `cwd` has a wiki config. | Always pass `--path`. If the user is ambiguous, follow the priority chain in [Path Handling Convention](#path-handling-convention). |
|
|
517
|
+
| Editing a `.md` and not re-indexing. | `lorewiki index --path "<WIKI>"` is incremental — unchanged files skip; just run it. |
|
|
518
|
+
| Using `Out-File` / `Set-Content` in PowerShell to write Markdown — may add BOM that breaks frontmatter parsing. | Use the agent's Write / Edit tool, or `[IO.File]::WriteAllText` with UTF-8 no-BOM. |
|
|
519
|
+
| Asking `lorewiki ask` and assuming an LLM answer. | Check the `used_llm` field in `ask --raw` output; fallback is normal and useful. |
|
|
520
|
+
| JSON output appears as `?` / replacement chars on Windows PowerShell. | The CLI forces UTF-8 stdout. If you still see it: (a) upgrade `lorewiki`; (b) as a fallback, prefix the command with `chcp 65001 |` to force the shell code page to UTF-8; (c) parse the prettified terminal panel for the `doc_path` and use the `Read` tool on the source `.md` file. |
|
|
521
|
+
| Brute-force scanning the whole filesystem when the wiki path is unknown. | Use the bounded-depth scan in [Path Handling Convention](#path-handling-convention) (Depth 3 against a small set of plausible roots such as the user's workspace and ``$HOME/Documents``), not ``Get-ChildItem -Recurse`` against an entire drive root. The exact list of roots is platform-specific; pick a small handful that makes sense for the user's machine, never a full recursive scan. |
|
|
522
|
+
|
|
523
|
+
## Quick Reference
|
|
524
|
+
|
|
525
|
+
```powershell
|
|
526
|
+
lorewiki --version
|
|
527
|
+
lorewiki init --path "<WIKI>"
|
|
528
|
+
lorewiki index --path "<WIKI>" [--rebuild]
|
|
529
|
+
lorewiki status --path "<WIKI>"
|
|
530
|
+
lorewiki search "<QUERY>" --path "<WIKI>" --mode {mix|bm25|hierarchy} --top-k N # default: JSON
|
|
531
|
+
lorewiki show "<DOC_PATH>" --path "<WIKI>" [--raw] # default: cleaned body
|
|
532
|
+
lorewiki tree "[<PREFIX>]" --path "<WIKI>" [--depth N] # hierarchy view
|
|
533
|
+
lorewiki add --title "<T>" [--module <M>] [--body <B> | --file <F> | stdin] # one-off write + reindex
|
|
534
|
+
lorewiki ask "<QUERY>" --path "<WIKI>" --top-k N --raw # JSON (default: Markdown)
|
|
535
|
+
lorewiki topic {list|use|create|show|...} # vault management
|
|
536
|
+
lorewiki config {list|get|set} ... --path "<WIKI>"
|
|
537
|
+
lorewiki clean --path "<WIKI>" [--dry-run] [--no-backup] # on-disk .md rewrite
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
## Decision Cheat-Sheet
|
|
541
|
+
|
|
542
|
+
| User intent | Command |
|
|
543
|
+
| ------------------------------------------------------ | ------------------------------------------------------------- |
|
|
544
|
+
| "look up X in the wiki" | `lorewiki search "X" --mode mix --top-k 5` |
|
|
545
|
+
| "show me the full content of doc Y" | `lorewiki show "<DOC_PATH>"` |
|
|
546
|
+
| "what's in the wiki / what modules exist" | `lorewiki tree` |
|
|
547
|
+
| "does the wiki explain how X is implemented?" | `lorewiki ask "how is X implemented?" --raw` |
|
|
548
|
+
| "what modules does the wiki have?" | `lorewiki tree --depth 2` |
|
|
549
|
+
| "remember this / save this / take a note about X" | `lorewiki add --title "X" --body "..."` |
|
|
550
|
+
| "store these N pages / scrape this site" | Drop the dir under the topic, then `lorewiki index` |
|
|
551
|
+
| "import my entire <some-tool> docs folder" | `lorewiki topic create <name> --source <docs-folder>` |
|
|
552
|
+
| "why does this query return no results?" | Re-run with `--mode bm25` to inspect scores |
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""Cross-platform skill installer — wheel-internal copy.
|
|
2
|
+
|
|
3
|
+
This module is the **runtime** version of ``skills/install.py``. Both
|
|
4
|
+
modules intentionally implement the same Tool catalog and the same
|
|
5
|
+
``_parse_choice`` grammar; ``skills/install.py`` is for developers
|
|
6
|
+
who clone the repository, while this one ships inside the wheel so
|
|
7
|
+
that a user who installed ``lorewiki`` from PyPI can run
|
|
8
|
+
``lorewiki install`` without first cloning the repo.
|
|
9
|
+
|
|
10
|
+
Source of truth for the tool catalog lives here (``TOOLS``). The
|
|
11
|
+
repo-side ``skills/install.py`` is kept independent on purpose:
|
|
12
|
+
``skills/install.py`` is part of the source checkout / dev workflow
|
|
13
|
+
and tests live in ``tests/test_skill_installer.py``; the wheel
|
|
14
|
+
inherits the same logic at install time without the cross-import
|
|
15
|
+
that would make ``lorewiki install`` depend on the repo tree.
|
|
16
|
+
|
|
17
|
+
Differences from ``skills/install.py``:
|
|
18
|
+
|
|
19
|
+
- The wheel has no notion of "the source tree" — we use
|
|
20
|
+
``importlib.resources`` to read the bundled ``SKILL.md`` from
|
|
21
|
+
``lorewiki.data.skill_template``.
|
|
22
|
+
- The wheel install is **copy-only** (no symlink mode) because
|
|
23
|
+
symlink semantics on Windows without Developer Mode are
|
|
24
|
+
unreliable, and the wheel can't ship a Windows-aware symlink
|
|
25
|
+
fallback policy cleanly.
|
|
26
|
+
- No ``--all-known`` / "ignore detection" flag: the wheel-side
|
|
27
|
+
``install`` is meant to be safe-by-default, only installing where
|
|
28
|
+
the tool's own config root already exists.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import sys
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from importlib import resources
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Final
|
|
39
|
+
|
|
40
|
+
SKILL_NAME: Final[str] = "lorewiki"
|
|
41
|
+
|
|
42
|
+
# Source of truth: ``lorewiki.data.skill_template.SKILL.md`` is
|
|
43
|
+
# bundled inside the wheel (see ``pyproject.toml`` ``[tool.hatch.build
|
|
44
|
+
# .targets.wheel].include``). The dev-tree ``skills/lorewiki/SKILL.md``
|
|
45
|
+
# is the canonical upstream; the wheel copy must be re-synced when
|
|
46
|
+
# the dev copy changes (a test enforces the two are byte-identical).
|
|
47
|
+
_WHEEL_SKILL_PACKAGE: Final[str] = "lorewiki.data.skill_template"
|
|
48
|
+
_WHEEL_SKILL_FILE: Final[str] = "SKILL.md"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _read_skill_template() -> str:
|
|
52
|
+
"""Read the bundled ``SKILL.md`` from the wheel.
|
|
53
|
+
|
|
54
|
+
Falls back to a hard error if the data file is missing — this
|
|
55
|
+
is a wheel-build configuration mistake (the package data was
|
|
56
|
+
not included in ``pyproject.toml``).
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
return resources.files(_WHEEL_SKILL_PACKAGE).joinpath(
|
|
60
|
+
_WHEEL_SKILL_FILE
|
|
61
|
+
).read_text(encoding="utf-8")
|
|
62
|
+
except (ModuleNotFoundError, FileNotFoundError) as exc:
|
|
63
|
+
msg = (
|
|
64
|
+
f"bundled skill template not found: "
|
|
65
|
+
f"{_WHEEL_SKILL_PACKAGE}/{_WHEEL_SKILL_FILE} ({exc}). "
|
|
66
|
+
"This is a wheel-build error — check pyproject.toml's "
|
|
67
|
+
"[tool.hatch.build.targets.wheel].include list."
|
|
68
|
+
)
|
|
69
|
+
raise FileNotFoundError(msg) from exc
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Tool catalog
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class Tool:
|
|
79
|
+
"""One supported AI tool's install target and aliases."""
|
|
80
|
+
|
|
81
|
+
id: str
|
|
82
|
+
label: str
|
|
83
|
+
primary: str
|
|
84
|
+
aliases: tuple[str, ...] = ()
|
|
85
|
+
|
|
86
|
+
def resolve(self, path_template: str) -> Path:
|
|
87
|
+
"""Expand ``<name>`` + ``$HOME`` / ``~`` / ``$XDG_*`` env vars."""
|
|
88
|
+
s = path_template.replace("<name>", SKILL_NAME)
|
|
89
|
+
s = os.path.expandvars(s)
|
|
90
|
+
s = os.path.expanduser(s)
|
|
91
|
+
return Path(s).resolve()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# Mirrors the catalog in ``skills/install.py``. If you edit one,
|
|
95
|
+
# edit the other — ``tests/test_skill_installer_wheel.py`` enforces
|
|
96
|
+
# equality of the visible-id set.
|
|
97
|
+
#
|
|
98
|
+
# ``primary`` is kept as a *raw* path template with ``$VAR`` placeholders
|
|
99
|
+
# rather than ``os.path.expandvars(os.environ.get(...))`` evaluated at
|
|
100
|
+
# module-import time. ``Tool.resolve`` runs ``os.path.expandvars`` on
|
|
101
|
+
# every call, so the env is read fresh — important for tests that
|
|
102
|
+
# monkey-patch ``XDG_CONFIG_HOME`` *after* the module is imported
|
|
103
|
+
# (and for runners that have a non-default env we want to honour
|
|
104
|
+
# *now*, not at import time).
|
|
105
|
+
TOOLS: Final[tuple[Tool, ...]] = (
|
|
106
|
+
Tool(
|
|
107
|
+
id="opencode",
|
|
108
|
+
label="opencode",
|
|
109
|
+
primary="$XDG_CONFIG_HOME/opencode/skills/<name>",
|
|
110
|
+
),
|
|
111
|
+
Tool(
|
|
112
|
+
id="claude",
|
|
113
|
+
label="Claude Code",
|
|
114
|
+
primary="~/.claude/skills/<name>",
|
|
115
|
+
),
|
|
116
|
+
Tool(
|
|
117
|
+
id="codex",
|
|
118
|
+
label="Codex CLI",
|
|
119
|
+
primary="$CODEX_HOME/skills/<name>",
|
|
120
|
+
),
|
|
121
|
+
Tool(
|
|
122
|
+
id="cursor",
|
|
123
|
+
label="Cursor",
|
|
124
|
+
primary="~/.cursor/skills/<name>",
|
|
125
|
+
# Cursor also auto-discovers ~/.agents/skills/ (interop path).
|
|
126
|
+
aliases=("~/.agents/skills/<name>",),
|
|
127
|
+
),
|
|
128
|
+
Tool(
|
|
129
|
+
id="gemini",
|
|
130
|
+
label="Gemini CLI",
|
|
131
|
+
primary="$GEMINI_HOME/skills/<name>",
|
|
132
|
+
aliases=("~/.agents/skills/<name>",),
|
|
133
|
+
),
|
|
134
|
+
Tool(
|
|
135
|
+
id="antigravity",
|
|
136
|
+
label="Google Antigravity",
|
|
137
|
+
primary="~/.gemini/antigravity/skills/<name>",
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# Detection
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _parent_exists(path: Path) -> bool:
|
|
148
|
+
"""True if the tool's *config root* exists (two levels above the skill dir).
|
|
149
|
+
|
|
150
|
+
For ``~/.config/opencode/skills/lorewiki`` we check
|
|
151
|
+
``~/.config/opencode/`` — the directory the tool itself creates
|
|
152
|
+
on first launch. Going further up would land on ``$HOME`` /
|
|
153
|
+
``C:\\`` which always exist and would mark every tool as
|
|
154
|
+
installed.
|
|
155
|
+
"""
|
|
156
|
+
return path.parent.parent.exists()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def detect_installed_tools() -> list[Tool]:
|
|
160
|
+
"""Return the subset of ``TOOLS`` whose skills dir is plausibly reachable."""
|
|
161
|
+
return [t for t in TOOLS if _parent_exists(t.resolve(t.primary))]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
# Interactive prompt parser (multi-select, ranges, ``a``, ``q``)
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _parse_choice(raw: str, max_n: int) -> list[int] | str | None:
|
|
170
|
+
"""Parse the interactive ``install into which?`` answer.
|
|
171
|
+
|
|
172
|
+
Returns one of:
|
|
173
|
+
|
|
174
|
+
- ``list[int]`` — 1-based indices of the chosen tools, in ascending
|
|
175
|
+
order with duplicates removed.
|
|
176
|
+
- ``"all"`` — install every detected tool.
|
|
177
|
+
- ``"quit"`` — user wants to exit without installing.
|
|
178
|
+
- ``None`` — input is invalid; caller should error out.
|
|
179
|
+
|
|
180
|
+
Accepted syntax (case-insensitive, whitespace stripped):
|
|
181
|
+
|
|
182
|
+
- ``""`` / ``q`` / ``quit`` → ``"quit"``
|
|
183
|
+
- ``a`` / ``all`` → ``"all"``
|
|
184
|
+
- ``3`` → ``[3]``
|
|
185
|
+
- ``1,3,5`` or ``1 3 5`` → ``[1, 3, 5]``
|
|
186
|
+
- ``2-4`` → ``[2, 3, 4]``
|
|
187
|
+
- ``1,3-5,6`` (mixed) → ``[1, 3, 4, 5, 6]``
|
|
188
|
+
|
|
189
|
+
Out-of-range numbers (anything outside ``1..max_n``), empty
|
|
190
|
+
selections, malformed tokens, and reversed ranges all return
|
|
191
|
+
``None``.
|
|
192
|
+
"""
|
|
193
|
+
c = raw.strip().lower()
|
|
194
|
+
if c in ("", "q", "quit"):
|
|
195
|
+
return "quit"
|
|
196
|
+
if c in ("a", "all"):
|
|
197
|
+
return "all"
|
|
198
|
+
result: set[int] = set()
|
|
199
|
+
for token in re.split(r"[,\s]+", c):
|
|
200
|
+
if not token:
|
|
201
|
+
continue
|
|
202
|
+
m = re.fullmatch(r"(\d+)(?:-(\d+))?", token)
|
|
203
|
+
if m is None:
|
|
204
|
+
return None
|
|
205
|
+
start = int(m.group(1))
|
|
206
|
+
end = int(m.group(2)) if m.group(2) else start
|
|
207
|
+
if start < 1 or end < 1 or start > max_n or end > max_n or start > end:
|
|
208
|
+
return None
|
|
209
|
+
result.update(range(start, end + 1))
|
|
210
|
+
return sorted(result) if result else None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def prompt_for_targets(detected: list[Tool], input_fn=input) -> list[Tool] | None:
|
|
214
|
+
"""Interactive multi-select prompt.
|
|
215
|
+
|
|
216
|
+
Returns the chosen tools, or ``None`` if the user quits /
|
|
217
|
+
provides invalid input. ``input_fn`` is injectable for tests.
|
|
218
|
+
"""
|
|
219
|
+
if not detected:
|
|
220
|
+
print("no AI tools detected on this machine.", file=sys.stderr)
|
|
221
|
+
return None
|
|
222
|
+
print("Detected tools on this machine:")
|
|
223
|
+
for i, tool in enumerate(detected, 1):
|
|
224
|
+
print(f" {i}. {tool.label} -> {tool.resolve(tool.primary)}")
|
|
225
|
+
print(" a. all of the above")
|
|
226
|
+
print(" q. quit")
|
|
227
|
+
choice = input_fn(
|
|
228
|
+
"install into which? [a / 1 / 1,3,5 / 1 3 5 / 2-4 / q]: "
|
|
229
|
+
)
|
|
230
|
+
parsed = _parse_choice(choice, len(detected))
|
|
231
|
+
if parsed == "quit":
|
|
232
|
+
return None
|
|
233
|
+
if parsed == "all":
|
|
234
|
+
return detected
|
|
235
|
+
if parsed is None:
|
|
236
|
+
print("invalid choice", file=sys.stderr)
|
|
237
|
+
return None
|
|
238
|
+
assert isinstance(parsed, list)
|
|
239
|
+
return [detected[i - 1] for i in parsed]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
# Install / uninstall primitives
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def install_skill(
|
|
248
|
+
tool: Tool,
|
|
249
|
+
*,
|
|
250
|
+
skill_text: str | None = None,
|
|
251
|
+
overwrite: bool = False,
|
|
252
|
+
) -> list[str]:
|
|
253
|
+
"""Copy the bundled ``SKILL.md`` to the tool's primary path (and aliases).
|
|
254
|
+
|
|
255
|
+
Returns the human-readable action lines (suitable for printing
|
|
256
|
+
directly to the user). ``overwrite=True`` re-writes an existing
|
|
257
|
+
target; the default is to refuse and emit a ``[skip]`` line so
|
|
258
|
+
the caller can surface a friendly hint.
|
|
259
|
+
"""
|
|
260
|
+
if skill_text is None:
|
|
261
|
+
skill_text = _read_skill_template()
|
|
262
|
+
primary = tool.resolve(tool.primary)
|
|
263
|
+
primary.parent.mkdir(parents=True, exist_ok=True)
|
|
264
|
+
actions: list[str] = []
|
|
265
|
+
if primary.exists() and not overwrite:
|
|
266
|
+
actions.append(f"[skip] {primary} (exists; pass --force to overwrite)")
|
|
267
|
+
else:
|
|
268
|
+
primary.write_text(skill_text, encoding="utf-8")
|
|
269
|
+
actions.append(f"[ok] wrote {primary}")
|
|
270
|
+
for alias_tmpl in tool.aliases:
|
|
271
|
+
alias = tool.resolve(alias_tmpl)
|
|
272
|
+
if alias == primary:
|
|
273
|
+
continue
|
|
274
|
+
alias.parent.mkdir(parents=True, exist_ok=True)
|
|
275
|
+
if alias.exists() and not overwrite:
|
|
276
|
+
actions.append(
|
|
277
|
+
f"[skip] {alias} (alias; exists; pass --force to overwrite)"
|
|
278
|
+
)
|
|
279
|
+
else:
|
|
280
|
+
alias.write_text(skill_text, encoding="utf-8")
|
|
281
|
+
actions.append(f"[ok] wrote {alias} (alias)")
|
|
282
|
+
return actions
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def uninstall_skill(tool: Tool) -> list[str]:
|
|
286
|
+
"""Remove the skill from the tool's primary path and aliases."""
|
|
287
|
+
actions: list[str] = []
|
|
288
|
+
for tmpl in (tool.primary, *tool.aliases):
|
|
289
|
+
target = tool.resolve(tmpl)
|
|
290
|
+
if target.exists() or target.is_symlink():
|
|
291
|
+
target.unlink()
|
|
292
|
+
actions.append(f"[rm] {target}")
|
|
293
|
+
else:
|
|
294
|
+
actions.append(f"[skip] {target} (not present)")
|
|
295
|
+
return actions
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
# Status report (one-liner per tool, ``[x]`` / ``[ ]``)
|
|
300
|
+
# ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _skill_installed(tool: Tool) -> bool:
|
|
304
|
+
"""True if the tool's primary OR any alias already has the skill."""
|
|
305
|
+
return any(tool.resolve(tmpl).exists() for tmpl in (tool.primary, *tool.aliases))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def status_report() -> str:
|
|
309
|
+
"""Return a human-readable status block for ``lorewiki install --status``."""
|
|
310
|
+
lines = ["LoreWiki skill status", "=" * 60]
|
|
311
|
+
for tool in TOOLS:
|
|
312
|
+
primary = tool.resolve(tool.primary)
|
|
313
|
+
marker = "[x]" if _skill_installed(tool) else "[ ]"
|
|
314
|
+
lines.append(f" {marker} {tool.label:<18} {primary}")
|
|
315
|
+
for alias_tmpl in tool.aliases:
|
|
316
|
+
alias = tool.resolve(alias_tmpl)
|
|
317
|
+
a_marker = "[x]" if alias.exists() else "[ ]"
|
|
318
|
+
lines.append(f" {a_marker} alias {alias}")
|
|
319
|
+
# Hint if the bundled template is readable from the wheel — this
|
|
320
|
+
# is also a soft smoke test of the package-data wiring.
|
|
321
|
+
try:
|
|
322
|
+
_read_skill_template()
|
|
323
|
+
except FileNotFoundError as exc:
|
|
324
|
+
lines.append("")
|
|
325
|
+
lines.append(f" ERROR: {exc}")
|
|
326
|
+
return "\n".join(lines)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ---------------------------------------------------------------------------
|
|
330
|
+
# High-level entry point used by the CLI
|
|
331
|
+
# ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def run(
|
|
335
|
+
*,
|
|
336
|
+
tool_ids: list[str] | None = None,
|
|
337
|
+
install_all: bool = False,
|
|
338
|
+
uninstall: bool = False,
|
|
339
|
+
force: bool = False,
|
|
340
|
+
show_status: bool = False,
|
|
341
|
+
input_fn=input,
|
|
342
|
+
) -> int:
|
|
343
|
+
"""Single entry point used by ``lorewiki install``.
|
|
344
|
+
|
|
345
|
+
``tool_ids`` is a list of canonical Tool.id values (e.g.
|
|
346
|
+
``["opencode", "claude"]``). When ``None`` and ``install_all``
|
|
347
|
+
is ``False``, the function falls back to the interactive
|
|
348
|
+
prompt. Returns the process exit code.
|
|
349
|
+
"""
|
|
350
|
+
if show_status:
|
|
351
|
+
print(status_report())
|
|
352
|
+
return 0
|
|
353
|
+
|
|
354
|
+
if tool_ids is not None:
|
|
355
|
+
by_id = {t.id: t for t in TOOLS}
|
|
356
|
+
unknown = [i for i in tool_ids if i not in by_id]
|
|
357
|
+
if unknown:
|
|
358
|
+
print(
|
|
359
|
+
f"unknown --tool ids: {unknown}; "
|
|
360
|
+
f"valid: {sorted(by_id)}",
|
|
361
|
+
file=sys.stderr,
|
|
362
|
+
)
|
|
363
|
+
return 2
|
|
364
|
+
targets: list[Tool] = [by_id[i] for i in tool_ids]
|
|
365
|
+
elif install_all:
|
|
366
|
+
targets = detect_installed_tools()
|
|
367
|
+
if not targets:
|
|
368
|
+
print("no AI tools detected on this machine.", file=sys.stderr)
|
|
369
|
+
return 1
|
|
370
|
+
else:
|
|
371
|
+
targets = prompt_for_targets(detect_installed_tools(), input_fn=input_fn) or []
|
|
372
|
+
if not targets:
|
|
373
|
+
return 0
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
skill_text = _read_skill_template()
|
|
377
|
+
except FileNotFoundError as exc:
|
|
378
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
379
|
+
return 1
|
|
380
|
+
|
|
381
|
+
print(f"source: bundled {_WHEEL_SKILL_PACKAGE}/{_WHEEL_SKILL_FILE}")
|
|
382
|
+
for tool in targets:
|
|
383
|
+
print(f"\n[{tool.label}]")
|
|
384
|
+
actions = (
|
|
385
|
+
uninstall_skill(tool)
|
|
386
|
+
if uninstall
|
|
387
|
+
else install_skill(tool, skill_text=skill_text, overwrite=force)
|
|
388
|
+
)
|
|
389
|
+
for line in actions:
|
|
390
|
+
print(line)
|
|
391
|
+
return 0
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "lorewiki"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.6"
|
|
8
8
|
description = "Local-first knowledge base for LLM-assisted coding, with hybrid retrieval (BM25 + hierarchy + optional vector) over SQLite FTS5."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -65,7 +65,14 @@ Source = "https://github.com/JochenYang/Lore-wiki"
|
|
|
65
65
|
packages = ["lorewiki"]
|
|
66
66
|
# ship the PEP 561 marker so downstream type checkers know we
|
|
67
67
|
# publish inline type hints.
|
|
68
|
-
|
|
68
|
+
# Bundle the LoreWiki agent skill (SKILL.md) as package data so a
|
|
69
|
+
# PyPI user can run ``lorewiki install`` without cloning the repo
|
|
70
|
+
# (see ``lorewiki.utils.skill_installer`` and
|
|
71
|
+
# ``lorewiki.cli.install_cmd``).
|
|
72
|
+
include = [
|
|
73
|
+
"lorewiki/py.typed",
|
|
74
|
+
"lorewiki/data/skill_template/SKILL.md",
|
|
75
|
+
]
|
|
69
76
|
exclude = [
|
|
70
77
|
"tests/",
|
|
71
78
|
"example_wiki/",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|