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.
Files changed (44) hide show
  1. miseenplace/__init__.py +3 -0
  2. miseenplace/__main__.py +5 -0
  3. miseenplace/assets.py +19 -0
  4. miseenplace/chat.py +26 -0
  5. miseenplace/cli.py +551 -0
  6. miseenplace/config.py +91 -0
  7. miseenplace/consent.py +230 -0
  8. miseenplace/demo_data/inbox.json +18 -0
  9. miseenplace/inbox.py +216 -0
  10. miseenplace/lock.py +63 -0
  11. miseenplace/model.py +313 -0
  12. miseenplace/personal.py +99 -0
  13. miseenplace/private.py +52 -0
  14. miseenplace/py.typed +0 -0
  15. miseenplace/render.py +200 -0
  16. miseenplace/skills/inbox-triage/SKILL.md +39 -0
  17. miseenplace/skills/inbox-triage/references/decisions.md +13 -0
  18. miseenplace/skills/inbox-watch/SKILL.md +28 -0
  19. miseenplace/skills/inbox-watch/references/claude-code.md +15 -0
  20. miseenplace/skills/inbox-watch/references/cron.md +11 -0
  21. miseenplace/skills/inbox-watch/references/launchd.md +28 -0
  22. miseenplace/skills/inbox-watch/references/systemd.md +33 -0
  23. miseenplace/skills/journal/SKILL.md +41 -0
  24. miseenplace/skills/learn-style/SKILL.md +28 -0
  25. miseenplace/skills/setup/SKILL.md +36 -0
  26. miseenplace/sources/__init__.py +33 -0
  27. miseenplace/sources/_template.py +73 -0
  28. miseenplace/sources/base.py +140 -0
  29. miseenplace/sources/demo.py +168 -0
  30. miseenplace/sources/zulip.py +456 -0
  31. miseenplace/storage/__init__.py +48 -0
  32. miseenplace/storage/_template.py +52 -0
  33. miseenplace/storage/base.py +74 -0
  34. miseenplace/storage/git.py +190 -0
  35. miseenplace/storage/local.py +115 -0
  36. miseenplace/storage/logmerge.py +36 -0
  37. miseenplace/store.py +281 -0
  38. miseenplace/tour.py +101 -0
  39. miseenplace/watch.py +64 -0
  40. miseenplace_cli-0.1.1.dist-info/METADATA +140 -0
  41. miseenplace_cli-0.1.1.dist-info/RECORD +44 -0
  42. miseenplace_cli-0.1.1.dist-info/WHEEL +4 -0
  43. miseenplace_cli-0.1.1.dist-info/entry_points.txt +3 -0
  44. miseenplace_cli-0.1.1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,3 @@
1
+ """miseenplace: every ask, in its place."""
2
+
3
+ __version__ = "0.1.1"
@@ -0,0 +1,5 @@
1
+ """`python -m miseenplace` is the same as the `miseenplace` command."""
2
+
3
+ from miseenplace.cli import main
4
+
5
+ raise SystemExit(main())
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