ideadump 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ideadump/__init__.py +51 -0
- ideadump/__main__.py +73 -0
- ideadump/base.py +38 -0
- ideadump/data/skills/ideadump/SKILL.md +81 -0
- ideadump/render.py +125 -0
- ideadump/search.py +99 -0
- ideadump/store.py +148 -0
- ideadump/sync.py +172 -0
- ideadump/tools.py +507 -0
- ideadump-0.0.2.dist-info/METADATA +97 -0
- ideadump-0.0.2.dist-info/RECORD +14 -0
- ideadump-0.0.2.dist-info/WHEEL +4 -0
- ideadump-0.0.2.dist-info/entry_points.txt +2 -0
- ideadump-0.0.2.dist-info/licenses/LICENSE +21 -0
ideadump/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""ideadump: a place to dump ideas, research them a little, and find them again.
|
|
2
|
+
|
|
3
|
+
Capture an idea in the owner's own words, attach research notes (a light pass at
|
|
4
|
+
capture, a deep one when it looks promising), tag and link ideas, and ask which ideas
|
|
5
|
+
relate to a query or to a page describing recent work. Ideas live in a ``dol`` store,
|
|
6
|
+
by default a local folder, optionally a checkout of a private git repository.
|
|
7
|
+
|
|
8
|
+
>>> from ideadump import add, find
|
|
9
|
+
>>> files = {}
|
|
10
|
+
>>> _ = add("Offline speech to text on the phone", tags="audio", files=files)
|
|
11
|
+
>>> find("speech recognition", files=files)["hits"][0]["tags"]
|
|
12
|
+
['audio']
|
|
13
|
+
|
|
14
|
+
The command line (``ideadump --help``) and the agent skill shipped in
|
|
15
|
+
``ideadump/data/skills/ideadump`` drive the same verbs.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from ideadump.base import IdeadumpError
|
|
19
|
+
from ideadump.store import data_dir, text_files
|
|
20
|
+
from ideadump.sync import sync, sync_init, sync_status
|
|
21
|
+
from ideadump.tools import (
|
|
22
|
+
TOOLS,
|
|
23
|
+
add,
|
|
24
|
+
find,
|
|
25
|
+
link,
|
|
26
|
+
ls,
|
|
27
|
+
note,
|
|
28
|
+
reindex,
|
|
29
|
+
show,
|
|
30
|
+
update,
|
|
31
|
+
where,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"IdeadumpError",
|
|
36
|
+
"TOOLS",
|
|
37
|
+
"add",
|
|
38
|
+
"data_dir",
|
|
39
|
+
"find",
|
|
40
|
+
"link",
|
|
41
|
+
"ls",
|
|
42
|
+
"note",
|
|
43
|
+
"reindex",
|
|
44
|
+
"show",
|
|
45
|
+
"sync",
|
|
46
|
+
"sync_init",
|
|
47
|
+
"sync_status",
|
|
48
|
+
"text_files",
|
|
49
|
+
"update",
|
|
50
|
+
"where",
|
|
51
|
+
]
|
ideadump/__main__.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# PYTHON_ARGCOMPLETE_OK
|
|
2
|
+
"""``ideadump`` on the command line: every verb of :data:`ideadump.tools.TOOLS`.
|
|
3
|
+
|
|
4
|
+
ideadump add "the owner's words, verbatim" --title "Short title" --tags "a, b" --source my-session
|
|
5
|
+
ideadump note <id> --file research.md --depth light
|
|
6
|
+
ideadump find "offline speech to text" # or --file recent-work.md
|
|
7
|
+
ideadump ls --status promising
|
|
8
|
+
ideadump show <id>
|
|
9
|
+
ideadump sync
|
|
10
|
+
|
|
11
|
+
``--json`` as the last argument prints the whole result instead of its text. Exit status is 0 on
|
|
12
|
+
success and 1 on an error, whose message says what to do next.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import functools
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
import cw
|
|
22
|
+
|
|
23
|
+
from ideadump import tools
|
|
24
|
+
from ideadump.base import IdeadumpError
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _command(func):
|
|
28
|
+
func = tools.without(func)
|
|
29
|
+
|
|
30
|
+
@functools.wraps(func)
|
|
31
|
+
def command(*args, **kwargs):
|
|
32
|
+
try:
|
|
33
|
+
return func(*args, **kwargs)
|
|
34
|
+
except IdeadumpError as error:
|
|
35
|
+
raise cw.CommandError(str(error)) from error
|
|
36
|
+
|
|
37
|
+
return command
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _egress(as_json):
|
|
41
|
+
def egress(result, *, out, err):
|
|
42
|
+
if as_json or not isinstance(result, dict):
|
|
43
|
+
print(
|
|
44
|
+
json.dumps(result, indent=2, ensure_ascii=False, default=str), file=out
|
|
45
|
+
)
|
|
46
|
+
else:
|
|
47
|
+
print(result.get("text", ""), file=out)
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
return egress
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main(argv=None):
|
|
54
|
+
"""Run the command line. The ``ideadump`` console script."""
|
|
55
|
+
for stream in (sys.stdout, sys.stderr):
|
|
56
|
+
getattr(stream, "reconfigure", lambda **_: None)(errors="backslashreplace")
|
|
57
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
58
|
+
as_json = bool(argv) and argv[-1] == "--json"
|
|
59
|
+
if as_json:
|
|
60
|
+
argv = argv[:-1]
|
|
61
|
+
commands = {f.__name__.replace("_", "-"): _command(f) for f in tools.TOOLS}
|
|
62
|
+
code = cw.dispatch(
|
|
63
|
+
commands,
|
|
64
|
+
argv,
|
|
65
|
+
prog="ideadump",
|
|
66
|
+
convention=cw.MODERN,
|
|
67
|
+
egress=_egress(as_json),
|
|
68
|
+
)
|
|
69
|
+
raise SystemExit(code)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
main()
|
ideadump/base.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Errors and constants shared across ideadump.
|
|
2
|
+
|
|
3
|
+
An idea is one folder in the store, ``ideas/<id>/``, holding its record
|
|
4
|
+
(``idea.json``, the single source of truth), a rendered ``README.md`` that a person
|
|
5
|
+
reads on GitHub or a phone, and its research notes under ``research/``. Nothing here
|
|
6
|
+
depends on any other module.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
APP_NAME = "ideadump"
|
|
12
|
+
DATA_DIR_ENVVAR = "IDEADUMP_DATA_DIR"
|
|
13
|
+
|
|
14
|
+
IDEAS_DIR = "ideas"
|
|
15
|
+
RECORD_FILE = "idea.json"
|
|
16
|
+
README_FILE = "README.md"
|
|
17
|
+
RESEARCH_DIR = "research"
|
|
18
|
+
|
|
19
|
+
#: Where an idea stands. ``new`` on capture; the owner (or an agent relaying the
|
|
20
|
+
#: owner's words) moves it on. ``promising`` is the cue for a deeper research pass.
|
|
21
|
+
STATUSES = ("new", "promising", "parked", "pursued", "dropped")
|
|
22
|
+
|
|
23
|
+
#: How deep a research note went. ``light`` is the pass made at capture (minutes, a
|
|
24
|
+
#: few sources); ``deep`` is a full ``do-research``-style report.
|
|
25
|
+
DEPTHS = ("light", "deep")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class IdeadumpError(Exception):
|
|
29
|
+
"""An error whose message says what to do next."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def record_key(idea_id: str) -> str:
|
|
33
|
+
"""The store key of an idea's record.
|
|
34
|
+
|
|
35
|
+
>>> record_key("2026-11-14-an-idea")
|
|
36
|
+
'ideas/2026-11-14-an-idea/idea.json'
|
|
37
|
+
"""
|
|
38
|
+
return f"{IDEAS_DIR}/{idea_id}/{RECORD_FILE}"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ideadump
|
|
3
|
+
description: Capture an idea the owner tells you, in their own words, research it a little (more if it looks promising), tag and link it, and find it again later, with the ideadump command. Use whenever the owner shares an idea unrelated to the current task or asks you to keep one ("I have an idea", "idea:", "jot this down", "dump this idea", "note this idea for later", "remind me of this idea"), asks about their ideas ("what ideas do I have about X", "list my ideas", "show the idea about Y", "research that idea deeper", "that one is promising", "park that idea"), or asks whether anything they have been working on relates to their ideas ("is any of my recent work related to my ideas", "anything in my ideas worth doing now"), typically during spare token budget. Ideas are stored in a local folder or a private git repository, never in the current project.
|
|
4
|
+
license: MIT
|
|
5
|
+
metadata:
|
|
6
|
+
audience: users
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# ideadump: a place to dump ideas, and find them again
|
|
10
|
+
|
|
11
|
+
The owner tells you an idea in passing, often in a session that has nothing to do with it, often dictated. Your job: keep it **in their words**, make it findable, give it a first look, and get out of the way. One command captures it; everything else is optional and cheap.
|
|
12
|
+
|
|
13
|
+
**First, is it for the current task?** An idea about the work in front of you ("I have an idea: do X this way") is an instruction: act on it, do not dump it. Dump only ideas meant for later or for something else, or when the owner says to keep it.
|
|
14
|
+
|
|
15
|
+
Ideas live in ideadump's data folder (`ideadump where`), never in the project you are working in. If that folder is a checkout of a private repository, `ideadump sync` pushes; otherwise it is local only, which is fine.
|
|
16
|
+
|
|
17
|
+
## Capture (always)
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
ideadump add "<the owner's words, verbatim>" --title "<short title>" --summary "<your clean reading>" --tags "<tag>, <tag>" --source "<this session's name>"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
- **Words verbatim**, dictation slips included. Never "fix" them in `words`; put your reading in `--summary`, and when you resolved a slip say so there ("'attic system' read as 'agentic system'"). If a slip changes the meaning and you cannot tell, ask one short question.
|
|
24
|
+
- **Title**: five words or fewer, the thing itself ("Idea dump tool", not "An idea about ideas").
|
|
25
|
+
- **Tags**: two to four, lowercase, reused from existing ones where they fit: `ideadump ls` shows the tags in use. Prefer the domain (`audio`, `fleet`, `writing`) and the kind (`tool`, `essay`, `research-question`, `business`).
|
|
26
|
+
- Then `ideadump find "<title and summary>"` (the new idea is among the hits; ignore its own id): if an earlier idea is the same or close, `ideadump link <id> <other> --why "<one line>"` (or tell the owner it is a duplicate and ask whether to merge).
|
|
27
|
+
- Reply to the owner in two lines at most: the id and title, and that a light look follows (or what it found).
|
|
28
|
+
|
|
29
|
+
**In a session that must keep its context small** (a lookout or switchboard session, or the middle of an unrelated task): run the `add` yourself, then hand the light research to a subagent with the id and these instructions, and carry on with what you were doing.
|
|
30
|
+
|
|
31
|
+
## Light research (at capture, a few minutes)
|
|
32
|
+
|
|
33
|
+
Search with the field's generic terms, not the owner's sentences: their words stay in the store, and outside search services see only queries.
|
|
34
|
+
|
|
35
|
+
Answer four questions, with sources, in a short Markdown note:
|
|
36
|
+
|
|
37
|
+
1. **What is this called?** The standard name for the idea or its field, if there is one.
|
|
38
|
+
2. **What exists already?** Prior art: tools, libraries, papers, products; one line each with a link. If you can search the owner's own packages and skills (in their fleet: `ir discover packages "<two phrasings>"`, `ir discover skills ...`), say what they already have.
|
|
39
|
+
3. **What is the crux?** The one thing that would make it hard, or the one thing that makes it easy.
|
|
40
|
+
4. **Verdict**: `promising`, `worth a deeper look`, `exists already (use X)`, or `park`, with one sentence of why.
|
|
41
|
+
|
|
42
|
+
References Vancouver style (`[1]`, `[2]` with a REFERENCES list, `[name](url)` links). Then:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
ideadump note <id> --file <note.md> --depth light
|
|
46
|
+
ideadump update <id> --status promising # only if the verdict says so; parked/dropped likewise
|
|
47
|
+
ideadump sync
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Write the note in a temporary folder, not in the current project.
|
|
51
|
+
|
|
52
|
+
## Deep research (on request, or when the owner agrees an idea is promising)
|
|
53
|
+
|
|
54
|
+
Load the `do-research` skill and run it on the idea at deep depth (its report shape, its Vancouver references). Store the report with `ideadump note <id> --file <report.md> --depth deep`, then `ideadump sync`. The report is as private as the idea. If the owner reads on a phone, give them a link only to a destination that is private and access-controlled (in their fleet, `annals publish`, whose links are private; or the idea's page in the private data repository). Never publish it anywhere public.
|
|
55
|
+
|
|
56
|
+
## Finding ideas
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
ideadump ls # newest first; --tag, --status, --limit
|
|
60
|
+
ideadump show <id or part of it> # the idea's page: words, summary, research, links
|
|
61
|
+
ideadump find "offline speech recognition" # ranked, with the words each hit shares with the query
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The data folder is browsable as it is: `README.md` at its root is the index (newest first, then by tag), and each `ideas/<id>/README.md` is an idea's page, so on GitHub the owner can read it from a phone.
|
|
65
|
+
|
|
66
|
+
## "Is anything I have been working on related to my ideas?"
|
|
67
|
+
|
|
68
|
+
1. Gather a page of text describing recent work into a temporary file: what the owner's sessions did lately (in their fleet: `crowsnest ledger` for the per-session ledgers, `ol` for open loops), recent issue and PR titles, recent commit subjects (`git log --since=7.days --format=%s` in the active repositories). A few hundred lines is fine.
|
|
69
|
+
2. `ideadump find --file <that file> --limit 15`.
|
|
70
|
+
3. The ranking is lexical, so **judge it**: read the top hits' pages (`ideadump show`), keep only those that really relate, and tell the owner, for each, which idea, which work, and the one thing the work could do for the idea (or the idea for the work). Say plainly when nothing relates.
|
|
71
|
+
4. Record a real connection: `ideadump update <id> --add-tags <project>` or a line in a research note.
|
|
72
|
+
|
|
73
|
+
## Other verbs
|
|
74
|
+
|
|
75
|
+
`ideadump update <id> --title/--summary/--tags/--add-tags/--status`, `ideadump link <a> <b> --why ...`, `ideadump reindex` (regenerate the pages), `ideadump sync-status`, `ideadump sync-init <owner>/<name>` (make the data folder a checkout of a private repository, created private if missing; an existing ideas folder is adopted). `--json` as the last argument of any verb prints the full result. Statuses: `new`, `promising`, `parked`, `pursued`, `dropped`.
|
|
76
|
+
|
|
77
|
+
## Never
|
|
78
|
+
|
|
79
|
+
- Never edit `words`; never delete an idea or a note the owner did not ask you to delete.
|
|
80
|
+
- Never put idea text in the current project, an issue, or anywhere public. If ideadump refuses because the data folder is inside another git work tree, tell the owner; never set `IDEADUMP_ALLOW_INSIDE_WORK_TREE` yourself.
|
|
81
|
+
- Never run deep research unasked on every idea: a light look at capture, deeper only on request or agreement.
|
ideadump/render.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Markdown views of the records: one ``README.md`` per idea and an index at the root.
|
|
2
|
+
|
|
3
|
+
The records (``idea.json``) are the source of truth; these pages are regenerated from
|
|
4
|
+
them on every write, so a person browsing the store on GitHub (or a phone) sees each
|
|
5
|
+
idea's folder open on its page, and the root opens on the index.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
|
|
15
|
+
from ideadump.base import IDEAS_DIR, README_FILE
|
|
16
|
+
|
|
17
|
+
__all__ = ["idea_page", "index_page", "research_depth", "created_at"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def research_depth(record: dict) -> str:
|
|
21
|
+
"""How far an idea has been researched: ``deep``, ``light`` or ``-``."""
|
|
22
|
+
depths = {note.get("depth") for note in record.get("research", [])}
|
|
23
|
+
return "deep" if "deep" in depths else "light" if depths else "-"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def created_at(record: dict) -> datetime:
|
|
27
|
+
"""When an idea was captured, comparable across machines in different time zones.
|
|
28
|
+
|
|
29
|
+
>>> a = {"created": "2026-11-14T23:30:00+01:00"}
|
|
30
|
+
>>> b = {"created": "2026-11-14T22:45:00+00:00"}
|
|
31
|
+
>>> created_at(a) < created_at(b)
|
|
32
|
+
True
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
moment = datetime.fromisoformat(record.get("created", ""))
|
|
36
|
+
except ValueError:
|
|
37
|
+
return datetime.min.replace(tzinfo=timezone.utc)
|
|
38
|
+
return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _text(value: str) -> str:
|
|
42
|
+
"""A title safe inside a Markdown table cell and link label."""
|
|
43
|
+
return re.sub(r"([|\[\]])", r"\\\1", value.replace("\n", " "))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _tags(record: dict) -> str:
|
|
47
|
+
return ", ".join(f"`{t}`" for t in record.get("tags", [])) or "-"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def idea_page(record: dict, *, titles: dict[str, str] | None = None) -> str:
|
|
51
|
+
"""The page of one idea: the owner's words, the summary, research and links.
|
|
52
|
+
|
|
53
|
+
>>> page = idea_page({"id": "x", "title": "T", "words": "w", "created": "2026-11-14"})
|
|
54
|
+
>>> page.splitlines()[0]
|
|
55
|
+
'# T'
|
|
56
|
+
"""
|
|
57
|
+
titles = titles or {}
|
|
58
|
+
lines = [
|
|
59
|
+
f"# {record['title']}",
|
|
60
|
+
"",
|
|
61
|
+
f"`{record['id']}` · {record.get('status', 'new')} · captured {record.get('created', '')[:10]}"
|
|
62
|
+
+ (f" from {record['source']}" if record.get("source") else "")
|
|
63
|
+
+ f" · research: {research_depth(record)}",
|
|
64
|
+
"",
|
|
65
|
+
f"Tags: {_tags(record)}",
|
|
66
|
+
"",
|
|
67
|
+
]
|
|
68
|
+
if record.get("summary"):
|
|
69
|
+
lines += ["## Summary", "", record["summary"].strip(), ""]
|
|
70
|
+
lines += ["## In the owner's words", ""]
|
|
71
|
+
lines += [
|
|
72
|
+
f"> {line}" if line else ">" for line in record["words"].strip().splitlines()
|
|
73
|
+
]
|
|
74
|
+
lines.append("")
|
|
75
|
+
if record.get("research"):
|
|
76
|
+
lines += ["## Research", ""]
|
|
77
|
+
for note in record["research"]:
|
|
78
|
+
lines.append(
|
|
79
|
+
f"- [{note.get('title') or note['file']}]({note['file']}) ({note['depth']}, {note['date'][:10]})"
|
|
80
|
+
)
|
|
81
|
+
lines.append("")
|
|
82
|
+
if record.get("links"):
|
|
83
|
+
lines += ["## Related ideas", ""]
|
|
84
|
+
for link in record["links"]:
|
|
85
|
+
other = link["id"]
|
|
86
|
+
label = titles.get(other, other)
|
|
87
|
+
why = f": {link['why']}" if link.get("why") else ""
|
|
88
|
+
lines.append(f"- [{_text(label)}](../{other}/{README_FILE}){why}")
|
|
89
|
+
lines.append("")
|
|
90
|
+
return "\n".join(lines)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def index_page(records: Iterable[dict]) -> str:
|
|
94
|
+
"""The root index: every idea, newest first, then the ideas under each tag.
|
|
95
|
+
|
|
96
|
+
>>> print(index_page([]).splitlines()[0])
|
|
97
|
+
# Ideas
|
|
98
|
+
"""
|
|
99
|
+
records = sorted(records, key=created_at, reverse=True)
|
|
100
|
+
|
|
101
|
+
def link(r):
|
|
102
|
+
return f"[{_text(r['title'])}]({IDEAS_DIR}/{r['id']}/{README_FILE})"
|
|
103
|
+
|
|
104
|
+
lines = [
|
|
105
|
+
"# Ideas",
|
|
106
|
+
"",
|
|
107
|
+
f"{len(records)} ideas. Generated by ideadump from the `idea.json` records; do not edit by hand.",
|
|
108
|
+
"",
|
|
109
|
+
"| Captured | Idea | Status | Research | Tags |",
|
|
110
|
+
"|---|---|---|---|---|",
|
|
111
|
+
]
|
|
112
|
+
for r in records:
|
|
113
|
+
lines.append(
|
|
114
|
+
f"| {r.get('created', '')[:10]} | {link(r)} | {r.get('status', 'new')} | {research_depth(r)} | {_tags(r)} |"
|
|
115
|
+
)
|
|
116
|
+
by_tag = defaultdict(list)
|
|
117
|
+
for r in records:
|
|
118
|
+
for tag in r.get("tags", []):
|
|
119
|
+
by_tag[tag].append(r)
|
|
120
|
+
if by_tag:
|
|
121
|
+
lines += ["", "## By tag", ""]
|
|
122
|
+
for tag in sorted(by_tag):
|
|
123
|
+
lines.append(f"- **{tag}**: " + ", ".join(link(r) for r in by_tag[tag]))
|
|
124
|
+
lines.append("")
|
|
125
|
+
return "\n".join(lines)
|
ideadump/search.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Rank ideas against a query: BM25 over words, standard library only.
|
|
2
|
+
|
|
3
|
+
The query can be a few words ("offline speech to text") or a page of text describing
|
|
4
|
+
recent work (ledgers, issue titles, commit messages): long queries are the "is
|
|
5
|
+
anything I've been working on related to my ideas?" case. The scorer is a seam: any
|
|
6
|
+
``score(query, docs) -> list[float]`` can be passed to :func:`rank`, for instance a
|
|
7
|
+
semantic scorer backed by the fleet's ``ir`` search.
|
|
8
|
+
|
|
9
|
+
>>> docs = {"a": "offline speech to text on a phone", "b": "a recipe for bread"}
|
|
10
|
+
>>> [hit["id"] for hit in rank("speech recognition offline", docs)]
|
|
11
|
+
['a']
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import math
|
|
17
|
+
import re
|
|
18
|
+
from collections import Counter
|
|
19
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
20
|
+
|
|
21
|
+
__all__ = ["tokens", "bm25", "rank"]
|
|
22
|
+
|
|
23
|
+
_WORD = re.compile(r"[a-z0-9][a-z0-9+#.-]*[a-z0-9+#]|[a-z0-9]", re.IGNORECASE)
|
|
24
|
+
|
|
25
|
+
# Function words carry no topic; dropping them keeps a page-long query from matching
|
|
26
|
+
# every idea on "the" and "with".
|
|
27
|
+
_STOPWORDS = frozenset(
|
|
28
|
+
"""a about above after again all also am an and any are as at be because been
|
|
29
|
+
before being below between both but by can could did do does doing down during
|
|
30
|
+
each few for from further had has have having he her here hers him his how i if
|
|
31
|
+
in into is it its itself just let like make me more most my no nor not now of off
|
|
32
|
+
on once only or other our out over own same she should so some such than that the
|
|
33
|
+
their them then there these they this those through to too under until up very
|
|
34
|
+
was we were what when where which while who whom why will with would you your
|
|
35
|
+
yours maybe really thing things want wanna gonna get got way use using one also""".split()
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def tokens(text: str) -> list[str]:
|
|
40
|
+
"""Lowercased content words of ``text``, with a light plural fold.
|
|
41
|
+
|
|
42
|
+
>>> tokens("The ideas, and an Idea!")
|
|
43
|
+
['idea', 'idea']
|
|
44
|
+
"""
|
|
45
|
+
out = []
|
|
46
|
+
for word in _WORD.findall(text.lower()):
|
|
47
|
+
if word in _STOPWORDS or len(word) < 2:
|
|
48
|
+
continue
|
|
49
|
+
if len(word) > 3 and word.endswith("s") and not word.endswith("ss"):
|
|
50
|
+
word = word[:-1]
|
|
51
|
+
out.append(word)
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def bm25(
|
|
56
|
+
query: str, docs: Sequence[str], *, k1: float = 1.5, b: float = 0.75
|
|
57
|
+
) -> list[float]:
|
|
58
|
+
"""BM25 score of each document for ``query`` (query terms counted once each)."""
|
|
59
|
+
doc_tokens = [tokens(d) for d in docs]
|
|
60
|
+
n = len(doc_tokens)
|
|
61
|
+
if not n:
|
|
62
|
+
return []
|
|
63
|
+
avg_len = sum(map(len, doc_tokens)) / n or 1.0
|
|
64
|
+
df = Counter(t for toks in doc_tokens for t in set(toks))
|
|
65
|
+
terms = set(tokens(query))
|
|
66
|
+
scores = []
|
|
67
|
+
for toks in doc_tokens:
|
|
68
|
+
tf = Counter(toks)
|
|
69
|
+
norm = k1 * (1 - b + b * len(toks) / avg_len)
|
|
70
|
+
score = 0.0
|
|
71
|
+
for t in terms & tf.keys():
|
|
72
|
+
idf = math.log(1 + (n - df[t] + 0.5) / (df[t] + 0.5))
|
|
73
|
+
score += idf * tf[t] * (k1 + 1) / (tf[t] + norm)
|
|
74
|
+
scores.append(score)
|
|
75
|
+
return scores
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def rank(
|
|
79
|
+
query: str,
|
|
80
|
+
docs: Mapping[str, str],
|
|
81
|
+
*,
|
|
82
|
+
limit: int = 10,
|
|
83
|
+
score: Callable[[str, Sequence[str]], list[float]] = bm25,
|
|
84
|
+
) -> list[dict]:
|
|
85
|
+
"""The ids of ``docs`` that match ``query``, best first, with score and shared terms."""
|
|
86
|
+
ids = list(docs)
|
|
87
|
+
scores = score(query, [docs[i] for i in ids])
|
|
88
|
+
query_terms = set(tokens(query))
|
|
89
|
+
hits = [
|
|
90
|
+
{
|
|
91
|
+
"id": i,
|
|
92
|
+
"score": round(s, 3),
|
|
93
|
+
"terms": sorted(query_terms & set(tokens(docs[i])))[:12],
|
|
94
|
+
}
|
|
95
|
+
for i, s in zip(ids, scores)
|
|
96
|
+
if s > 0
|
|
97
|
+
]
|
|
98
|
+
hits.sort(key=lambda h: -h["score"])
|
|
99
|
+
return hits[:limit]
|
ideadump/store.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Where ideas live: a ``MutableMapping`` of ``/``-separated path to text.
|
|
2
|
+
|
|
3
|
+
Every verb takes a ``files`` store, so the default local folder can be swapped for any
|
|
4
|
+
other ``dol`` store (or a plain ``dict`` in tests) without touching the rest. The
|
|
5
|
+
default is a folder, the data root:
|
|
6
|
+
|
|
7
|
+
1. the ``data_dir`` argument, else
|
|
8
|
+
2. ``$IDEADUMP_DATA_DIR``, else
|
|
9
|
+
3. ``~/.local/share/ideadump`` (``$XDG_DATA_HOME`` and ``%LOCALAPPDATA%`` honoured).
|
|
10
|
+
|
|
11
|
+
To keep ideas in a private git repository, make the data root a checkout of it with
|
|
12
|
+
``ideadump sync-init <owner>/<name>`` (see :mod:`ideadump.sync`); the store
|
|
13
|
+
itself does not change.
|
|
14
|
+
|
|
15
|
+
>>> data_dir("somewhere").is_absolute()
|
|
16
|
+
True
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import time
|
|
23
|
+
from collections.abc import Iterator, MutableMapping
|
|
24
|
+
from contextlib import contextmanager
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from ideadump.base import APP_NAME, DATA_DIR_ENVVAR, IdeadumpError
|
|
29
|
+
|
|
30
|
+
__all__ = ["data_dir", "text_files", "resolve_files", "writing"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def data_dir(data_dir: str | os.PathLike | None = None) -> Path:
|
|
34
|
+
"""The data root: the argument, else ``$IDEADUMP_DATA_DIR``, else the user data folder."""
|
|
35
|
+
if data_dir:
|
|
36
|
+
return Path(os.path.abspath(os.path.expanduser(str(data_dir))))
|
|
37
|
+
configured = os.environ.get(DATA_DIR_ENVVAR)
|
|
38
|
+
if configured:
|
|
39
|
+
path = Path(os.path.expanduser(configured))
|
|
40
|
+
if not path.is_absolute():
|
|
41
|
+
raise IdeadumpError(
|
|
42
|
+
f"${DATA_DIR_ENVVAR} must be an absolute path, got {configured!r}"
|
|
43
|
+
)
|
|
44
|
+
return path
|
|
45
|
+
if os.name == "nt": # pragma: no cover - exercised on Windows CI only
|
|
46
|
+
base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
|
|
47
|
+
return Path(base) / APP_NAME
|
|
48
|
+
base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
|
|
49
|
+
return Path(base) / APP_NAME
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def text_files(root: str | os.PathLike) -> MutableMapping[str, str]:
|
|
53
|
+
"""A ``dol`` files store under ``root``: ``/``-separated keys, UTF-8 text values.
|
|
54
|
+
|
|
55
|
+
Folders are made on write and never on read; a delete is permanent.
|
|
56
|
+
"""
|
|
57
|
+
from dol import Files, mk_dirs_if_missing, wrap_kvs
|
|
58
|
+
|
|
59
|
+
root = str(root)
|
|
60
|
+
os.makedirs(root, exist_ok=True)
|
|
61
|
+
files = mk_dirs_if_missing(Files(root, delete_func=os.remove))
|
|
62
|
+
return wrap_kvs(
|
|
63
|
+
files,
|
|
64
|
+
key_of_id=lambda k: k.replace(os.sep, "/"),
|
|
65
|
+
id_of_key=lambda k: k.replace("/", os.sep),
|
|
66
|
+
obj_of_data=lambda b: b.decode("utf-8", errors="replace"),
|
|
67
|
+
data_of_obj=lambda s: s.encode("utf-8"),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def resolve_files(
|
|
72
|
+
files: MutableMapping[str, str] | None = None, *, data_dir_: Any = None
|
|
73
|
+
) -> MutableMapping[str, str]:
|
|
74
|
+
"""``files`` if given, else the text store over the data root."""
|
|
75
|
+
return text_files(data_dir(data_dir_)) if files is None else files
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_LOCK_FILE = ".ideadump.lock"
|
|
79
|
+
_LOCK_TIMEOUT = 30.0 # seconds to wait for another writer
|
|
80
|
+
_LOCK_STALE = 120.0 # a lock older than this was left by a crashed process
|
|
81
|
+
_LOCK_POLL = 0.05
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
ALLOW_WORK_TREE_ENVVAR = "IDEADUMP_ALLOW_INSIDE_WORK_TREE"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def enclosing_work_tree(root: Path) -> Path | None:
|
|
88
|
+
"""The git work tree ``root`` sits inside, when it is not a checkout of its own."""
|
|
89
|
+
if (root / ".git").exists():
|
|
90
|
+
return None
|
|
91
|
+
return next((p for p in root.parents if (p / ".git").exists()), None)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _refuse_foreign_work_tree(root: Path) -> None:
|
|
95
|
+
enclosing = enclosing_work_tree(root)
|
|
96
|
+
if enclosing and not os.environ.get(ALLOW_WORK_TREE_ENVVAR):
|
|
97
|
+
raise IdeadumpError(
|
|
98
|
+
f"the data folder {root} is inside the git work tree {enclosing}, so a commit "
|
|
99
|
+
"there would carry the ideas. Point $IDEADUMP_DATA_DIR elsewhere, or, if that "
|
|
100
|
+
f"repository is private and meant for them, set ${ALLOW_WORK_TREE_ENVVAR}=1"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@contextmanager
|
|
105
|
+
def writing(
|
|
106
|
+
files: MutableMapping[str, str] | None = None,
|
|
107
|
+
*,
|
|
108
|
+
data_dir_: Any = None,
|
|
109
|
+
timeout: float = _LOCK_TIMEOUT,
|
|
110
|
+
) -> Iterator[MutableMapping[str, str]]:
|
|
111
|
+
"""The store, held exclusively while a verb reads, changes and rewrites it.
|
|
112
|
+
|
|
113
|
+
Several sessions may capture ideas at the same moment; each write verb reads every
|
|
114
|
+
record, picks a free id, and rewrites the index, so two of them interleaving would
|
|
115
|
+
lose an idea. For the default folder store this takes a lock file in the data root
|
|
116
|
+
(created exclusively, so it works on every platform); a store passed in by the
|
|
117
|
+
caller is the caller's to serialise.
|
|
118
|
+
"""
|
|
119
|
+
if files is not None:
|
|
120
|
+
yield files
|
|
121
|
+
return
|
|
122
|
+
root = data_dir(data_dir_)
|
|
123
|
+
_refuse_foreign_work_tree(root)
|
|
124
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
lock = root / _LOCK_FILE
|
|
126
|
+
deadline = time.monotonic() + timeout
|
|
127
|
+
while True:
|
|
128
|
+
try:
|
|
129
|
+
fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
130
|
+
os.write(fd, str(os.getpid()).encode())
|
|
131
|
+
os.close(fd)
|
|
132
|
+
break
|
|
133
|
+
except FileExistsError:
|
|
134
|
+
try:
|
|
135
|
+
if time.time() - lock.stat().st_mtime > _LOCK_STALE:
|
|
136
|
+
lock.unlink(missing_ok=True)
|
|
137
|
+
continue
|
|
138
|
+
except FileNotFoundError:
|
|
139
|
+
continue
|
|
140
|
+
if time.monotonic() > deadline:
|
|
141
|
+
raise IdeadumpError(
|
|
142
|
+
f"another ideadump write holds {lock}; if no other ideadump is running, delete that file"
|
|
143
|
+
) from None
|
|
144
|
+
time.sleep(_LOCK_POLL)
|
|
145
|
+
try:
|
|
146
|
+
yield text_files(root)
|
|
147
|
+
finally:
|
|
148
|
+
lock.unlink(missing_ok=True)
|