miseenplace-cli 0.1.1__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.
- miseenplace/__init__.py +3 -0
- miseenplace/__main__.py +5 -0
- miseenplace/assets.py +19 -0
- miseenplace/chat.py +26 -0
- miseenplace/cli.py +551 -0
- miseenplace/config.py +91 -0
- miseenplace/consent.py +230 -0
- miseenplace/demo_data/inbox.json +18 -0
- miseenplace/inbox.py +216 -0
- miseenplace/lock.py +63 -0
- miseenplace/model.py +313 -0
- miseenplace/personal.py +99 -0
- miseenplace/private.py +52 -0
- miseenplace/py.typed +0 -0
- miseenplace/render.py +200 -0
- miseenplace/skills/inbox-triage/SKILL.md +39 -0
- miseenplace/skills/inbox-triage/references/decisions.md +13 -0
- miseenplace/skills/inbox-watch/SKILL.md +28 -0
- miseenplace/skills/inbox-watch/references/claude-code.md +15 -0
- miseenplace/skills/inbox-watch/references/cron.md +11 -0
- miseenplace/skills/inbox-watch/references/launchd.md +28 -0
- miseenplace/skills/inbox-watch/references/systemd.md +33 -0
- miseenplace/skills/journal/SKILL.md +41 -0
- miseenplace/skills/learn-style/SKILL.md +28 -0
- miseenplace/skills/setup/SKILL.md +36 -0
- miseenplace/sources/__init__.py +33 -0
- miseenplace/sources/_template.py +73 -0
- miseenplace/sources/base.py +140 -0
- miseenplace/sources/demo.py +168 -0
- miseenplace/sources/zulip.py +456 -0
- miseenplace/storage/__init__.py +48 -0
- miseenplace/storage/_template.py +52 -0
- miseenplace/storage/base.py +74 -0
- miseenplace/storage/git.py +190 -0
- miseenplace/storage/local.py +115 -0
- miseenplace/storage/logmerge.py +36 -0
- miseenplace/store.py +281 -0
- miseenplace/tour.py +101 -0
- miseenplace/watch.py +64 -0
- miseenplace_cli-0.1.1.dist-info/METADATA +140 -0
- miseenplace_cli-0.1.1.dist-info/RECORD +44 -0
- miseenplace_cli-0.1.1.dist-info/WHEEL +4 -0
- miseenplace_cli-0.1.1.dist-info/entry_points.txt +3 -0
- miseenplace_cli-0.1.1.dist-info/licenses/LICENSE +202 -0
miseenplace/__init__.py
ADDED
miseenplace/__main__.py
ADDED
miseenplace/assets.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Where the bundled files live: in a wheel next to the package, else in a source checkout."""
|
|
2
|
+
|
|
3
|
+
from importlib import resources
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
CHECKOUT = Path(__file__).parents[2]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def bundled(packaged_name: str, checkout_name: str) -> Path:
|
|
10
|
+
packaged = Path(str(resources.files("miseenplace") / packaged_name))
|
|
11
|
+
return packaged if packaged.is_dir() else CHECKOUT / checkout_name
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def demo_data_dir() -> Path:
|
|
15
|
+
return bundled("demo_data", "examples/demo-data")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def skills_dir() -> Path:
|
|
19
|
+
return bundled("skills", "skills")
|
miseenplace/chat.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""The two small types every layer shares: where a message goes and what a message is."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class Target:
|
|
8
|
+
"""Where a message goes. `label` is for humans only; sources fill it when they resolve."""
|
|
9
|
+
|
|
10
|
+
to: str # channel name, or comma-separated people for a direct message
|
|
11
|
+
thread: str | None = None # topic/thread inside a channel; None means a direct message
|
|
12
|
+
reply_to: str | None = None # id of the message being answered, if any
|
|
13
|
+
label: str = ""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Message:
|
|
18
|
+
id: str
|
|
19
|
+
where: str # human label of the thread: "#channel > topic" or "DM: names"
|
|
20
|
+
sender: str
|
|
21
|
+
ts: float # unix seconds
|
|
22
|
+
text: str
|
|
23
|
+
mentioned: bool = False # the message names me (or a group I am in)
|
|
24
|
+
mine: bool = False # I wrote it; `new` never returns these
|
|
25
|
+
url: str | None = None
|
|
26
|
+
target: Target | None = None # where a reply to this message goes; None if unknown
|
miseenplace/cli.py
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
"""Command line: parses arguments, then calls the store, the inbox and a source."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from functools import partial
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
from miseenplace import __version__, personal
|
|
10
|
+
from miseenplace.assets import skills_dir
|
|
11
|
+
from miseenplace.chat import Target
|
|
12
|
+
from miseenplace.config import CONFIG_NAME, resolve_root
|
|
13
|
+
from miseenplace.consent import mark_draft, pending_drafts
|
|
14
|
+
from miseenplace.inbox import (
|
|
15
|
+
Inbox,
|
|
16
|
+
InboxItem,
|
|
17
|
+
render_json,
|
|
18
|
+
render_lines,
|
|
19
|
+
title_of,
|
|
20
|
+
topic_of,
|
|
21
|
+
)
|
|
22
|
+
from miseenplace.model import (
|
|
23
|
+
KINDS,
|
|
24
|
+
STATES,
|
|
25
|
+
Extras,
|
|
26
|
+
Item,
|
|
27
|
+
MiseError,
|
|
28
|
+
eta_key,
|
|
29
|
+
priority_key,
|
|
30
|
+
select,
|
|
31
|
+
top,
|
|
32
|
+
)
|
|
33
|
+
from miseenplace.render import brief, diary_line, format_message, line, standup
|
|
34
|
+
from miseenplace.sources import open_source
|
|
35
|
+
from miseenplace.sources.base import Digestible, Source, SourceError
|
|
36
|
+
from miseenplace.storage.base import StorageError
|
|
37
|
+
from miseenplace.store import Store
|
|
38
|
+
from miseenplace.tour import tour
|
|
39
|
+
from miseenplace.watch import watch
|
|
40
|
+
|
|
41
|
+
CONFIG_TEMPLATE = """\
|
|
42
|
+
# miseenplace config. Personal: keep this file out of public repositories.
|
|
43
|
+
actors = ["me", "agent"] # who may write; --by picks one
|
|
44
|
+
default_actor = "agent"
|
|
45
|
+
# default_source = "zulip" # or set MISEENPLACE_SOURCE
|
|
46
|
+
|
|
47
|
+
# [storage]
|
|
48
|
+
# backend = "git" # local (default) | git | "package.module:Class"
|
|
49
|
+
# remote = "git@example.com:me/my-miseenplace-data.git"
|
|
50
|
+
|
|
51
|
+
# [sources.zulip]
|
|
52
|
+
# noise_streams = ["alerts"] # skipped unless a message @-mentions me
|
|
53
|
+
# noise_topics = ["Build Failure"]
|
|
54
|
+
# participation_days = 14
|
|
55
|
+
"""
|
|
56
|
+
SOURCE_CMDS = (
|
|
57
|
+
"new", "thread", "digest", "draft", "drafts", "send", "users", "inbox", "triage", "watch",
|
|
58
|
+
) # fmt: skip
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---- parser -------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
MIN_INTERVAL = 10.0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def interval(text: str) -> float:
|
|
68
|
+
try:
|
|
69
|
+
seconds = float(text)
|
|
70
|
+
except ValueError:
|
|
71
|
+
raise argparse.ArgumentTypeError(f"{text!r} is not a number of seconds") from None
|
|
72
|
+
if seconds < MIN_INTERVAL:
|
|
73
|
+
raise argparse.ArgumentTypeError(f"must be at least {MIN_INTERVAL:g} seconds, got {text}")
|
|
74
|
+
return seconds
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def add_work_args(s: argparse.ArgumentParser) -> None:
|
|
78
|
+
s.add_argument("--topic", help="group items under a topic; '' clears")
|
|
79
|
+
s.add_argument("--context", help="1-3 lines: why this exists, who asked; '' clears")
|
|
80
|
+
s.add_argument("--next", help="where you left off / the next concrete step; '' clears")
|
|
81
|
+
s.add_argument("--blocker", help="who or what it is waiting on; '' clears")
|
|
82
|
+
s.add_argument("--think", help="slug of a longer-form notebook for big work; '' clears")
|
|
83
|
+
s.add_argument("--priority", help="p0 (highest) .. p3, or none")
|
|
84
|
+
s.add_argument("--spent", help="time to ADD to the total: 30m, 1h or 1h30m")
|
|
85
|
+
s.add_argument(
|
|
86
|
+
"--decision",
|
|
87
|
+
dest="decisions",
|
|
88
|
+
action="append",
|
|
89
|
+
metavar="WHAT::WHY",
|
|
90
|
+
help="append a decision (repeatable); decisions are never rewritten",
|
|
91
|
+
)
|
|
92
|
+
s.add_argument("--tag", dest="tags", action="append", help="add a tag (repeatable)")
|
|
93
|
+
s.add_argument("--untag", dest="untags", action="append", help="remove a tag (repeatable)")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def add_write_args(s: argparse.ArgumentParser) -> None:
|
|
97
|
+
s.add_argument("--by", help=f"actor to attribute the write to ({CONFIG_NAME}); default_actor")
|
|
98
|
+
s.add_argument("--no-sync", action="store_true", help="skip pull/render/commit/push")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def add_target_args(s: argparse.ArgumentParser) -> None:
|
|
102
|
+
s.add_argument("--channel", help="channel/stream name (with --topic)")
|
|
103
|
+
s.add_argument("--topic", help="topic/thread inside the channel")
|
|
104
|
+
s.add_argument("--dm", help="direct message: comma-separated names, handles or ids")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def add_journal_parsers(
|
|
108
|
+
sub: "argparse._SubParsersAction[argparse.ArgumentParser]",
|
|
109
|
+
) -> None:
|
|
110
|
+
ad = sub.add_parser("add", help="add an action item")
|
|
111
|
+
ad.add_argument("title")
|
|
112
|
+
ad.add_argument("--kind", choices=KINDS, default="mine", help="mine | waiting | reminder")
|
|
113
|
+
st = sub.add_parser("set", help="change an item: state, eta, notes, work context")
|
|
114
|
+
st.add_argument("item", help="id or unique title substring of an open item")
|
|
115
|
+
st.add_argument("--state", choices=STATES)
|
|
116
|
+
dn = sub.add_parser("done", help="close an item as done")
|
|
117
|
+
dr = sub.add_parser("drop", help="close an item as dropped")
|
|
118
|
+
for s in (dn, dr):
|
|
119
|
+
s.add_argument("item", help="id or unique title substring of an open item")
|
|
120
|
+
for s in (ad, st):
|
|
121
|
+
s.add_argument("--eta", help="YYYY-MM-DD, MM-DD, today, tomorrow, +Nd, mon..sun or none")
|
|
122
|
+
s.add_argument("--who", help="counterparty: who asked, or who is waited on")
|
|
123
|
+
s.add_argument("--ref", action="append", help="link (http/https) to attach; repeatable")
|
|
124
|
+
add_work_args(s)
|
|
125
|
+
for s in (ad, st, dn, dr):
|
|
126
|
+
s.add_argument("--note", help="append a note")
|
|
127
|
+
add_write_args(s)
|
|
128
|
+
lg = sub.add_parser("log", help="diary entry not tied to an item")
|
|
129
|
+
lg.add_argument("text")
|
|
130
|
+
lg.add_argument("--topic")
|
|
131
|
+
lg.add_argument("--tag", dest="tags", action="append", help="repeatable")
|
|
132
|
+
lg.add_argument("--spent", help="30m, 1h or 1h30m")
|
|
133
|
+
lg.add_argument("--ref", action="append", help="link (http/https); repeatable")
|
|
134
|
+
add_write_args(lg)
|
|
135
|
+
ls = sub.add_parser("list", help="list items, optionally filtered")
|
|
136
|
+
ls.add_argument("--all", action="store_true", help="include done and dropped")
|
|
137
|
+
ls.add_argument("--topic", help="exact topic, case-insensitive")
|
|
138
|
+
ls.add_argument("--tag")
|
|
139
|
+
ls.add_argument("--priority", help="p0..p3")
|
|
140
|
+
ls.add_argument("--kind", choices=KINDS)
|
|
141
|
+
ls.add_argument("--who", help="substring, case-insensitive")
|
|
142
|
+
ls.add_argument("--sort", choices=("eta", "priority"), default="eta", help="eta or priority")
|
|
143
|
+
tp = sub.add_parser("top", help="open items of kind mine by priority, then eta")
|
|
144
|
+
tp.add_argument("-n", type=int, default=5, help="how many (default 5)")
|
|
145
|
+
br = sub.add_parser("brief", help="full view of one item, or every open item in a topic")
|
|
146
|
+
br.add_argument("key", help="item id, topic, or unique title substring")
|
|
147
|
+
sub.add_parser("sync", help="pull, render Action Items.md, publish (storage backend decides)")
|
|
148
|
+
su = sub.add_parser("standup", help="Did / Next / Waiting on")
|
|
149
|
+
su.add_argument("--since", default="yesterday", help="yesterday, today, -Nd or YYYY-MM-DD")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def add_inbox_parsers(
|
|
153
|
+
sub: "argparse._SubParsersAction[argparse.ArgumentParser]",
|
|
154
|
+
) -> None:
|
|
155
|
+
ib = sub.add_parser("inbox", help="ONLY the actionable new messages, one line each")
|
|
156
|
+
ib.add_argument("--cursor", default="inbox", help="this consumer's own read position")
|
|
157
|
+
ib.add_argument("--json", action="store_true", help="machine-readable output")
|
|
158
|
+
ib.add_argument("--all", action="store_true", help="also show auto-ignored and handled")
|
|
159
|
+
ib.add_argument("--verbose", action="store_true", help="banner and full text, for humans")
|
|
160
|
+
tr = sub.add_parser("triage", help="handle one inbox item in one call")
|
|
161
|
+
tr.add_argument("id", help="the id from `inbox`")
|
|
162
|
+
tr.add_argument("--todo", action="store_true", help="add a journal item (kind mine)")
|
|
163
|
+
tr.add_argument("--waiting", action="store_true", help="add a journal item (waiting on them)")
|
|
164
|
+
tr.add_argument("--ignore", action="store_true", help="nothing to do")
|
|
165
|
+
tr.add_argument("--reply-draft", metavar="TEXT", help="store a quote-reply; nothing is posted")
|
|
166
|
+
tr.add_argument("--eta", help="for --todo/--waiting")
|
|
167
|
+
tr.add_argument("--topic", help="for --todo/--waiting; default: the thread's topic")
|
|
168
|
+
tr.add_argument("--no-sync", action="store_true")
|
|
169
|
+
wt = sub.add_parser("watch", help="block until a new actionable item, print it, exit 0")
|
|
170
|
+
wt.add_argument("--cursor", default="watcher", help="own read position (default watcher)")
|
|
171
|
+
wt.add_argument(
|
|
172
|
+
"--interval",
|
|
173
|
+
type=interval,
|
|
174
|
+
default=300,
|
|
175
|
+
help=f"seconds between polls (min {MIN_INTERVAL:g})",
|
|
176
|
+
)
|
|
177
|
+
wt.add_argument("--once", action="store_true", help="one poll, then exit 0 (cron, launchd)")
|
|
178
|
+
wt.add_argument("--max-cycles", type=int, default=0, help="stop with exit 3 after N polls")
|
|
179
|
+
n = sub.add_parser("new", help="raw new messages for me since this cursor; silent if none")
|
|
180
|
+
n.add_argument("--cursor", default="default", help="this consumer's own read position")
|
|
181
|
+
n.add_argument("--init", action="store_true", help="reset the cursor to now")
|
|
182
|
+
n.add_argument("--quiet", action="store_true")
|
|
183
|
+
n.add_argument("--width", type=int, default=300)
|
|
184
|
+
t = sub.add_parser("thread", help="read a thread or a DM conversation")
|
|
185
|
+
add_target_args(t)
|
|
186
|
+
t.add_argument("-n", type=int, default=40)
|
|
187
|
+
t.add_argument("--width", type=int, default=2000)
|
|
188
|
+
dg = sub.add_parser("digest", help="threads of the last N days, grouped (for humans)")
|
|
189
|
+
dg.add_argument("--days", type=float, default=5)
|
|
190
|
+
dg.add_argument("--all", action="store_true", help="include noise channels")
|
|
191
|
+
dg.add_argument("--mine", action="store_true", help="only my own messages")
|
|
192
|
+
dg.add_argument("--width", type=int, default=1200)
|
|
193
|
+
d = sub.add_parser("draft", help="store the exact text of a message; nothing is posted")
|
|
194
|
+
add_target_args(d)
|
|
195
|
+
d.add_argument("--reply-to", help="id of the message to quote-reply to")
|
|
196
|
+
d.add_argument("text", help="message text, or - to read stdin")
|
|
197
|
+
sub.add_parser("drafts", help="list drafts not yet sent")
|
|
198
|
+
s = sub.add_parser("send", help="post a stored draft; takes only a draft id")
|
|
199
|
+
s.add_argument("draft_id", help="id printed by `draft`, after the human said yes to its text")
|
|
200
|
+
verdict = s.add_mutually_exclusive_group()
|
|
201
|
+
verdict.add_argument("--mark-sent", action="store_true", help="after checking: it WAS posted")
|
|
202
|
+
verdict.add_argument(
|
|
203
|
+
"--mark-unsent", action="store_true", help="after checking: it was NOT posted"
|
|
204
|
+
)
|
|
205
|
+
u = sub.add_parser("users", help="look up people")
|
|
206
|
+
u.add_argument("query")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def add_personal_parsers(
|
|
210
|
+
sub: "argparse._SubParsersAction[argparse.ArgumentParser]",
|
|
211
|
+
) -> None:
|
|
212
|
+
nr = sub.add_parser("note-request", help="log the user's exact words to the request log")
|
|
213
|
+
nr.add_argument("text", help="verbatim text, or - to read stdin")
|
|
214
|
+
nr.add_argument("--no-sync", action="store_true")
|
|
215
|
+
rq = sub.add_parser("requests", help="read back the request log, newest last")
|
|
216
|
+
rq.add_argument("-n", type=int, default=10)
|
|
217
|
+
sy = sub.add_parser("style", help="the user's writing style and sent-message corpus")
|
|
218
|
+
ss = sy.add_subparsers(dest="style_cmd", required=True, metavar="action")
|
|
219
|
+
ss.add_parser("show", help="print the style file for the source")
|
|
220
|
+
sl = ss.add_parser("learn", help="fetch my sent messages into the corpus (dedup by id)")
|
|
221
|
+
sl.add_argument("--days", type=float, default=90)
|
|
222
|
+
sp = ss.add_parser("sample", help="print my most recent sent messages from the corpus")
|
|
223
|
+
sp.add_argument("-n", type=int, default=200)
|
|
224
|
+
st = ss.add_parser("set", help="write the style file from text (use - for stdin)")
|
|
225
|
+
st.add_argument("text")
|
|
226
|
+
for p in (sl, st):
|
|
227
|
+
p.add_argument("--no-sync", action="store_true")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
231
|
+
p = argparse.ArgumentParser(
|
|
232
|
+
prog="miseenplace",
|
|
233
|
+
description="Every ask, in its place. Start with: miseenplace inbox. See docs/cli.md.",
|
|
234
|
+
epilog="Environment: MISEENPLACE_DIR (data root), MISEENPLACE_SOURCE (default source).",
|
|
235
|
+
)
|
|
236
|
+
p.add_argument("--version", action="version", version=f"miseenplace {__version__}")
|
|
237
|
+
p.add_argument(
|
|
238
|
+
"--dir",
|
|
239
|
+
help="data root (default $MISEENPLACE_DIR or ~/.local/share/miseenplace)",
|
|
240
|
+
)
|
|
241
|
+
p.add_argument("--source", help="chat source: zulip, demo (default $MISEENPLACE_SOURCE)")
|
|
242
|
+
sub = p.add_subparsers(dest="cmd", required=True, metavar="command")
|
|
243
|
+
add_journal_parsers(sub)
|
|
244
|
+
add_inbox_parsers(sub)
|
|
245
|
+
add_personal_parsers(sub)
|
|
246
|
+
sub.add_parser("init", help="create the data root and a config template")
|
|
247
|
+
dm = sub.add_parser("demo", help="two-minute guided tour on fictional data, no account")
|
|
248
|
+
dm.add_argument("--keep-dir", dest="demo_dir", help="run in this empty directory")
|
|
249
|
+
sub.add_parser("skills", help="print the path of the bundled agent skills")
|
|
250
|
+
return p
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ---- journal commands -----------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def work_options(a: argparse.Namespace) -> Extras:
|
|
257
|
+
keys = (
|
|
258
|
+
"topic", "context", "next", "blocker", "think", "priority", "spent",
|
|
259
|
+
"decisions", "tags", "untags",
|
|
260
|
+
) # fmt: skip
|
|
261
|
+
return cast(Extras, {k: v for k in keys if (v := getattr(a, k, None)) is not None})
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def run_list(store: Store, a: argparse.Namespace) -> None:
|
|
265
|
+
rows = select(store.load()[1], a.all, a.topic, a.tag, a.priority, a.kind, a.who)
|
|
266
|
+
for i in sorted(rows, key=priority_key if a.sort == "priority" else eta_key):
|
|
267
|
+
print(f"[{i['kind']}] " + line(i))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def run_write(store: Store, a: argparse.Namespace) -> None:
|
|
271
|
+
if a.cmd == "log":
|
|
272
|
+
entry = store.log(a.text, a.topic, a.tags, a.spent, a.ref, a.by)
|
|
273
|
+
print(diary_line(entry))
|
|
274
|
+
label = f"log ({entry['by']})"
|
|
275
|
+
else:
|
|
276
|
+
if a.cmd == "add":
|
|
277
|
+
item = store.add(a.title, a.kind, a.eta, a.who, a.ref, a.note, a.by, **work_options(a))
|
|
278
|
+
else:
|
|
279
|
+
item = store.update(
|
|
280
|
+
a.item, a.cmd, a.by,
|
|
281
|
+
getattr(a, "state", None), getattr(a, "eta", None),
|
|
282
|
+
getattr(a, "who", None), getattr(a, "ref", None),
|
|
283
|
+
a.note, **work_options(a),
|
|
284
|
+
) # fmt: skip
|
|
285
|
+
print(line(item))
|
|
286
|
+
label = f"{a.cmd} #{item['id']} ({store.actor(a.by)})"
|
|
287
|
+
if not a.no_sync:
|
|
288
|
+
store.sync(label)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def run_init(store: Store, a: argparse.Namespace) -> None:
|
|
292
|
+
store.root.mkdir(parents=True, exist_ok=True)
|
|
293
|
+
config = store.root / CONFIG_NAME
|
|
294
|
+
if not config.exists():
|
|
295
|
+
config.write_text(CONFIG_TEMPLATE)
|
|
296
|
+
print(f"ready: {store.root}")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def run_journal(store: Store, a: argparse.Namespace) -> None:
|
|
300
|
+
if a.cmd == "standup":
|
|
301
|
+
print(standup(store.load()[1], store.read_log(), a.since))
|
|
302
|
+
elif a.cmd == "list":
|
|
303
|
+
run_list(store, a)
|
|
304
|
+
elif a.cmd == "top":
|
|
305
|
+
for i in top(store.load()[1], a.n):
|
|
306
|
+
print(line(i))
|
|
307
|
+
elif a.cmd == "brief":
|
|
308
|
+
print(brief(store.load()[1], a.key))
|
|
309
|
+
elif a.cmd == "sync":
|
|
310
|
+
store.sync()
|
|
311
|
+
elif a.cmd == "init":
|
|
312
|
+
run_init(store, a)
|
|
313
|
+
else:
|
|
314
|
+
run_write(store, a)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# ---- inbox commands -------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def target_of(a: argparse.Namespace) -> Target:
|
|
321
|
+
if getattr(a, "reply_to", None):
|
|
322
|
+
return Target("", None, a.reply_to)
|
|
323
|
+
if a.dm:
|
|
324
|
+
return Target(a.dm)
|
|
325
|
+
if a.channel and a.topic:
|
|
326
|
+
return Target(a.channel, a.topic)
|
|
327
|
+
raise MiseError("name a destination: --dm NAMES, or --channel C --topic T (or --reply-to ID)")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def read_text(text: str) -> str:
|
|
331
|
+
return sys.stdin.read() if text == "-" else text
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def run_digest(src: Source, a: argparse.Namespace) -> None:
|
|
335
|
+
if not isinstance(src, Digestible):
|
|
336
|
+
raise MiseError(f"source {src.name!r} does not offer digest; use inbox/thread")
|
|
337
|
+
groups: dict[str, list[str]] = {}
|
|
338
|
+
for m in src.digest(a.days, a.all, a.mine):
|
|
339
|
+
groups.setdefault(m.where, []).append(format_message(m, a.width))
|
|
340
|
+
print(f"# {sum(map(len, groups.values()))} messages in {a.days:g}d, {len(groups)} threads")
|
|
341
|
+
for where, rows in groups.items():
|
|
342
|
+
print(f"\n===== {where}")
|
|
343
|
+
print("\n".join(rows))
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def run_new(src: Source, a: argparse.Namespace) -> None:
|
|
347
|
+
"""The cursor moves only after every line has been written out."""
|
|
348
|
+
batch = src.fetch(a.cursor, a.init)
|
|
349
|
+
if a.init and not a.quiet:
|
|
350
|
+
print(f"baseline set for cursor {a.cursor!r}")
|
|
351
|
+
for m in batch.messages:
|
|
352
|
+
print(f"{m.where} | {format_message(m, a.width)}".replace("\n", " "))
|
|
353
|
+
sys.stdout.flush()
|
|
354
|
+
src.advance(a.cursor, batch)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def run_inbox(store: Store, src: Source, a: argparse.Namespace) -> None:
|
|
358
|
+
inbox = Inbox(store.root, src)
|
|
359
|
+
inbox.ingest(a.cursor)
|
|
360
|
+
try:
|
|
361
|
+
inbox.recheck() # drop asks the user answered meanwhile, e.g. in the chat app itself
|
|
362
|
+
except SourceError as e:
|
|
363
|
+
print(f"miseenplace: could not re-check answered threads ({e})", file=sys.stderr)
|
|
364
|
+
items = inbox.items() if a.all else inbox.open_items()
|
|
365
|
+
if a.json:
|
|
366
|
+
print(render_json(items))
|
|
367
|
+
elif a.verbose:
|
|
368
|
+
print(f"# {len(items)} open")
|
|
369
|
+
for i in items:
|
|
370
|
+
print(f"{i.line()}\n {' '.join(i.text.split())}\n {i.url or ''}")
|
|
371
|
+
elif items:
|
|
372
|
+
print(render_lines(items))
|
|
373
|
+
sys.stdout.flush()
|
|
374
|
+
inbox.mark_shown([i for i in items if i.status == "open"], a.cursor)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
HANDLED = ("todo", "waiting", "ignored", "answered")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def filed_item(store: Store, mark: str) -> Item | None:
|
|
381
|
+
"""The journal item this inbox message already produced, if any (retry safety)."""
|
|
382
|
+
return next((i for i in store.load()[1] if mark in (i["context"] or "")), None)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def triage_journal(store: Store, src_name: str, item: InboxItem, a: argparse.Namespace) -> str:
|
|
386
|
+
mark = f"inbox:{src_name}:{item.id}"
|
|
387
|
+
existing = filed_item(store, mark)
|
|
388
|
+
if existing: # an earlier attempt added it but did not finish: reuse, never duplicate
|
|
389
|
+
return f"#{existing['id']}"
|
|
390
|
+
extra = cast(
|
|
391
|
+
Extras,
|
|
392
|
+
{
|
|
393
|
+
"topic": a.topic or topic_of(item),
|
|
394
|
+
"context": f"{item.who} in {item.where}: {item.ask()} [{mark}]",
|
|
395
|
+
},
|
|
396
|
+
)
|
|
397
|
+
kind = "waiting" if a.waiting else "mine"
|
|
398
|
+
refs = [item.url] if item.url else None
|
|
399
|
+
created = store.add(title_of(item), kind, a.eta, item.who, refs, **extra)
|
|
400
|
+
return f"#{created['id']}"
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def run_triage(store: Store, src: Source, a: argparse.Namespace) -> None:
|
|
404
|
+
actions = [a.todo, a.waiting, a.ignore, bool(a.reply_draft)]
|
|
405
|
+
if not any(actions):
|
|
406
|
+
raise MiseError("say what to do: --todo, --waiting, --ignore or --reply-draft TEXT")
|
|
407
|
+
if sum(actions) > 1:
|
|
408
|
+
raise MiseError("pick one of --todo, --waiting, --ignore, --reply-draft")
|
|
409
|
+
if (a.ignore or a.reply_draft) and (a.eta or a.topic):
|
|
410
|
+
raise MiseError("--eta and --topic only go with --todo or --waiting")
|
|
411
|
+
inbox = Inbox(store.root, src)
|
|
412
|
+
item = inbox.get(a.id)
|
|
413
|
+
if item.status in HANDLED:
|
|
414
|
+
raise MiseError(f"inbox item {item.id} is already {item.status} {item.ref}".rstrip())
|
|
415
|
+
if a.ignore:
|
|
416
|
+
inbox.set_status(item, "ignored")
|
|
417
|
+
print(f"ignored {item.id}")
|
|
418
|
+
if a.todo or a.waiting:
|
|
419
|
+
ref = triage_journal(store, src.name, item, a)
|
|
420
|
+
inbox.set_status(item, "waiting" if a.waiting else "todo", ref)
|
|
421
|
+
print(f"{'waiting' if a.waiting else 'todo'} {ref}")
|
|
422
|
+
if not a.no_sync:
|
|
423
|
+
store.sync(f"triage {item.id}")
|
|
424
|
+
if a.reply_draft:
|
|
425
|
+
d = src.draft(Target("", reply_to=item.id), read_text(a.reply_draft))
|
|
426
|
+
inbox.set_status(item, "drafted", d.id)
|
|
427
|
+
print(d.preview())
|
|
428
|
+
print(f"send: miseenplace send {d.id} (only after the human says yes to this text)")
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def run_source_cmd(store: Store, a: argparse.Namespace) -> None:
|
|
432
|
+
if a.cmd == "watch": # opens its own source each cycle: a bad login is logged, not fatal
|
|
433
|
+
make = partial(open_source, source_name(a), store.root, store.config)
|
|
434
|
+
raise SystemExit(watch(store.root, make, a.cursor, a.interval, a.once, a.max_cycles))
|
|
435
|
+
src = open_source(source_name(a), store.root, store.config)
|
|
436
|
+
if a.cmd == "inbox":
|
|
437
|
+
run_inbox(store, src, a)
|
|
438
|
+
elif a.cmd == "triage":
|
|
439
|
+
run_triage(store, src, a)
|
|
440
|
+
elif a.cmd == "new":
|
|
441
|
+
run_new(src, a)
|
|
442
|
+
elif a.cmd == "thread":
|
|
443
|
+
for m in src.thread(target_of(a), a.n):
|
|
444
|
+
print(format_message(m, a.width))
|
|
445
|
+
elif a.cmd == "digest":
|
|
446
|
+
run_digest(src, a)
|
|
447
|
+
elif a.cmd == "draft":
|
|
448
|
+
d = src.draft(target_of(a), read_text(a.text))
|
|
449
|
+
print(d.preview())
|
|
450
|
+
print(f"send: miseenplace send {d.id} (only after the human says yes to this text)")
|
|
451
|
+
elif a.cmd == "drafts":
|
|
452
|
+
for d in pending_drafts(store.root, src.name):
|
|
453
|
+
print(d.preview())
|
|
454
|
+
elif a.cmd == "send" and (a.mark_sent or a.mark_unsent):
|
|
455
|
+
d = mark_draft(store.root, src.name, a.draft_id, a.mark_sent)
|
|
456
|
+
print(f"draft {d.id} marked {'sent' if a.mark_sent else 'unsent (sendable again)'}")
|
|
457
|
+
elif a.cmd == "send":
|
|
458
|
+
print(f"sent {src.name} message {src.send(a.draft_id)}")
|
|
459
|
+
else:
|
|
460
|
+
for p in src.users(a.query):
|
|
461
|
+
print(f"{p.name}\t{p.handle}\tbot={p.bot}")
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
# ---- personal data: requests and style ---------------------------------------------
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def source_name(a: argparse.Namespace) -> str | None:
|
|
468
|
+
return cast(str | None, a.source or os.environ.get("MISEENPLACE_SOURCE"))
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def run_note_request(store: Store, a: argparse.Namespace) -> None:
|
|
472
|
+
r = personal.note_request(store.storage, read_text(a.text), source_name(a))
|
|
473
|
+
print(f"noted {r.ts}")
|
|
474
|
+
if not a.no_sync:
|
|
475
|
+
store.sync("note-request")
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def run_requests(store: Store, a: argparse.Namespace) -> None:
|
|
479
|
+
for r in personal.read_requests(store.storage, a.n):
|
|
480
|
+
print(f"{r.ts}\t{r.source or '-'}\t{r.text}")
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def run_style(store: Store, a: argparse.Namespace) -> None:
|
|
484
|
+
src = open_source(source_name(a), store.root, store.config)
|
|
485
|
+
name = src.name
|
|
486
|
+
if a.style_cmd == "show":
|
|
487
|
+
print(
|
|
488
|
+
personal.read_style(store.storage, name) or f"no style yet for {name}: run learn-style"
|
|
489
|
+
)
|
|
490
|
+
elif a.style_cmd == "learn":
|
|
491
|
+
added, total = personal.learn(store.storage, src, a.days)
|
|
492
|
+
print(f"corpus {name}: +{added}, {total} total")
|
|
493
|
+
if not a.no_sync:
|
|
494
|
+
store.sync("style learn")
|
|
495
|
+
elif a.style_cmd == "sample":
|
|
496
|
+
for s in personal.sample(store.storage, name, a.n):
|
|
497
|
+
print(" ".join(s.text.split()))
|
|
498
|
+
else:
|
|
499
|
+
personal.write_style(store.storage, name, read_text(a.text))
|
|
500
|
+
print(f"wrote {personal.style_name(name)}")
|
|
501
|
+
if not a.no_sync:
|
|
502
|
+
store.sync("style set")
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# ---- entry --------------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
READ_CMDS = ("list", "top", "brief", "standup")
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def refresh_for_read(store: Store) -> None:
|
|
512
|
+
"""Pull before reading; offline is not fatal for a read, so say so and use the local copy."""
|
|
513
|
+
try:
|
|
514
|
+
store.refresh()
|
|
515
|
+
except StorageError as e:
|
|
516
|
+
print(f"miseenplace: using local copy ({e})", file=sys.stderr)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def run(a: argparse.Namespace) -> None:
|
|
520
|
+
if a.cmd == "demo":
|
|
521
|
+
tour(a.demo_dir)
|
|
522
|
+
return
|
|
523
|
+
if a.cmd == "skills":
|
|
524
|
+
print(skills_dir())
|
|
525
|
+
return
|
|
526
|
+
store = Store(resolve_root(a.dir))
|
|
527
|
+
if a.cmd in READ_CMDS:
|
|
528
|
+
refresh_for_read(store)
|
|
529
|
+
if a.cmd in SOURCE_CMDS:
|
|
530
|
+
run_source_cmd(store, a)
|
|
531
|
+
elif a.cmd == "note-request":
|
|
532
|
+
run_note_request(store, a)
|
|
533
|
+
elif a.cmd == "requests":
|
|
534
|
+
run_requests(store, a)
|
|
535
|
+
elif a.cmd == "style":
|
|
536
|
+
run_style(store, a)
|
|
537
|
+
else:
|
|
538
|
+
run_journal(store, a)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def main(argv: list[str] | None = None) -> int:
|
|
542
|
+
args = build_parser().parse_args(argv)
|
|
543
|
+
try:
|
|
544
|
+
run(args)
|
|
545
|
+
except SourceError as e:
|
|
546
|
+
print(f"miseenplace: {e}", file=sys.stderr)
|
|
547
|
+
return 2
|
|
548
|
+
except MiseError as e:
|
|
549
|
+
print(f"miseenplace: {e}", file=sys.stderr)
|
|
550
|
+
return 1
|
|
551
|
+
return 0
|