Messages, threads, acknowledgements, claims, bands, tasks, review items and stop flags for a team of AI agents; the chat page and board; host hooks and MCP server; autopilot; tools/watch.py. Put under version control after the weekend's changes so they have history and can be rolled back. Local only.
123 lines
4.3 KiB
Python
123 lines
4.3 KiB
Python
"""Small shared helpers: times, scopes, the compressed-text detector and Harbor's error type."""
|
|
import datetime
|
|
import fnmatch
|
|
import re
|
|
import time
|
|
|
|
# Run-together words such as "full222", "stub23HarnessBug" or "correctOffsetsWereWrong". Hex tokens (commit hashes,
|
|
# addresses) and ordinary identifiers (PascalCase names, g_snake_case globals) are not counted.
|
|
SQUASHED = re.compile(r"[A-Za-z]{3,}\d{2,}[A-Za-z]*|\b[a-z]{3,}[A-Z][a-z]{2,}[A-Z][a-z]{2,}[A-Za-z]*\b")
|
|
HEXLIKE = re.compile(r"(?:0x)?[0-9A-Fa-f]+")
|
|
PRIORITIES = {"urgent": 0, "high": 1, "normal": 2, "low": 3}
|
|
PRIORITY_NAMES = {v: k for k, v in PRIORITIES.items()}
|
|
|
|
|
|
class HarborError(Exception):
|
|
"""A refused operation; the message says why and what to do instead."""
|
|
|
|
|
|
def squashed_tokens(text):
|
|
found = []
|
|
for token in re.split(r"[\s,;:()\[\]{}<>\"'`/|=+]+", text):
|
|
token = token.strip(".!?-")
|
|
if not token or HEXLIKE.fullmatch(token) or "_" in token:
|
|
continue
|
|
if SQUASHED.search(token):
|
|
found.append(token)
|
|
return found
|
|
|
|
|
|
def utc(ts=None):
|
|
return datetime.datetime.fromtimestamp(time.time() if ts is None else ts, datetime.timezone.utc)
|
|
|
|
|
|
def iso(dt):
|
|
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
def parse_iso(text):
|
|
return datetime.datetime.strptime(text, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc)
|
|
|
|
|
|
def _span(text):
|
|
m = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([mhd])\s*", str(text))
|
|
if not m:
|
|
return None
|
|
return datetime.timedelta(seconds=float(m.group(1)) * {"m": 60, "h": 3600, "d": 86400}[m.group(2)])
|
|
|
|
|
|
def parse_since(text, now):
|
|
"""'8h', '90m', '2d' or an ISO time -> ISO time in the past."""
|
|
span = _span(text)
|
|
if span is not None:
|
|
return iso(now - span)
|
|
try:
|
|
return iso(parse_iso(str(text).strip()))
|
|
except ValueError:
|
|
raise HarborError(f"since {text!r}: use 8h, 90m, 2d or 2026-10-02T13:25:00Z")
|
|
|
|
|
|
def parse_until(text, now):
|
|
"""'30m', '2h', '1d' or an ISO time -> ISO time in the future (due dates, 'away until')."""
|
|
span = _span(text)
|
|
if span is not None:
|
|
return iso(now + span)
|
|
try:
|
|
return iso(parse_iso(str(text).strip()))
|
|
except ValueError:
|
|
raise HarborError(f"time {text!r}: use 30m, 2h, 1d or 2026-10-03T13:00:00Z")
|
|
|
|
|
|
def parse_priority(value):
|
|
if isinstance(value, int) and value in PRIORITY_NAMES:
|
|
return value
|
|
key = str(value or "normal").lower()
|
|
if key not in PRIORITIES:
|
|
raise HarborError(f"priority must be one of {', '.join(PRIORITIES)}")
|
|
return PRIORITIES[key]
|
|
|
|
|
|
def parse_scope(scope):
|
|
"""'fn:0x529350' / '0x529350' / 'range:0x440000-0x47FFFF' -> (canonical, lo, hi); others keep lo/hi None."""
|
|
text = scope.strip()
|
|
m = re.fullmatch(r"(?:fn:)?(0x[0-9A-Fa-f]+)", text)
|
|
if m:
|
|
value = int(m.group(1), 16)
|
|
return f"fn:0x{value:06X}", value, value
|
|
m = re.fullmatch(r"range:(0x[0-9A-Fa-f]+)-(0x[0-9A-Fa-f]+)", text)
|
|
if m:
|
|
lo, hi = int(m.group(1), 16), int(m.group(2), 16)
|
|
if lo > hi:
|
|
raise HarborError(f"range {text}: start is after end")
|
|
return f"range:0x{lo:06X}-0x{hi:06X}", lo, hi
|
|
m = re.fullmatch(r"(file|name|topic|task):(.+)", text)
|
|
if m:
|
|
value = m.group(2).strip()
|
|
f = re.match(r"(?:.*[/\\])?([0-9A-Fa-f]{6})_[^/\\]*\.c$", value) if m.group(1) == "file" else None
|
|
if f: # a function's file: it stands for that address
|
|
address = int(f.group(1), 16)
|
|
return f"file:{value}", address, address
|
|
return f"{m.group(1)}:{value}", None, None
|
|
raise HarborError(f"scope {scope!r}: use fn:0xADDR, range:0xLO-0xHI, name:Symbol, file:path, task:text or topic:text")
|
|
|
|
|
|
def scopes_overlap(a, b):
|
|
"""a, b: (scope, lo, hi)."""
|
|
if a[1] is not None and b[1] is not None:
|
|
return a[1] <= b[2] and b[1] <= a[2]
|
|
if a[1] is not None or b[1] is not None:
|
|
return False
|
|
ka, va = a[0].split(":", 1)
|
|
kb, vb = b[0].split(":", 1)
|
|
if ka != kb:
|
|
return False
|
|
if ka == "file":
|
|
va, vb = va.replace("\\", "/").lower(), vb.replace("\\", "/").lower()
|
|
return va == vb or fnmatch.fnmatch(va, vb) or fnmatch.fnmatch(vb, va)
|
|
return va.lower() == vb.lower()
|
|
|
|
|
|
def first_line(text, limit=160):
|
|
line = (text or "").strip().split("\n", 1)[0]
|
|
return line if len(line) <= limit else line[:limit - 1] + "…"
|