Skill: extraterrest/workcrm (version 0.1.0-alpha.4)

# WorkCRM (OpenClaw Skill) A lightweight, local-first CRM with an explicit confirmation gate. Product constraints (locked): - Writes happen only after explicit confirmation: reply `记` to confirm, `不记` to reject. - Drafts are retained for auditability. ## Chat usage (recommended) Use any of these to reliably trigger CRM behavior: - `crm: ...` - `记一下：...` - `先出草稿：...` Flow: 1) You send a message. 2) WorkCRM replies with a draft. 3) You reply `记` or `不记`. Note: this alpha skill provides the core engine + storage. Chat routing glue depends on your OpenClaw agent config. ## Local CLI (for verification/dev) ### Generate a draft ```bash python -m workcrm draft "crm: talked to Alice, follow up next week" ``` This prints a JSON payload including a human message + pending draft id. ## Storage - Default DB path: `~/.openclaw/workcrm/workcrm.sqlite3` - Override with env var: `WORKCRM_DB_PATH=/path/to/workcrm.sqlite3` - Or pass `--db /path/to/workcrm.sqlite3` to the CLI. ## Implementation notes - Deterministic ordering is enforced for lists. - Schema migrations are applied automatically on first use.

path: README.md
content: # workcrm

Local-first CRM skill for OpenClaw with an explicit confirmation gate.

- Draft first
- Write only after ` 记 ` / ` 不记 `
- Drafts retained for audit

See `SKILL.md`.


path: pyproject.toml
content: [build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

[project]
name = "workcrm"
version = "0.1.0-alpha.4"
description = "WorkCRM OpenClaw skill (local-first CRM with explicit confirmation flow)"
requires-python = ">=3.11"
dependencies = []

[tool.pytest.ini_options]
addopts = "-q"
testpaths = ["tests"]


path: tests/test_confirm_gate.py
content: from __future__ import annotations

from pathlib import Path

from workcrm import WorkCRMEngine, WorkCRMRepo


def test_no_write_without_confirm(tmp_path: Path) -> None:
    repo = WorkCRMRepo(tmp_path / "wcrm.sqlite")
    eng = WorkCRMEngine(repo)

    # Propose a log
    r1 = eng.handle('log company=Acme project=Site summary="Call"')
    assert r1.pending is not None
    assert not r1.wrote

    # Random text should not write
    r2 = eng.handle("ok")
    assert not r2.wrote

    exported = repo.export_json()
    assert exported["activities"] == []

    # Cancel should discard
    r3 = eng.handle("不记")
    assert not r3.wrote
    assert eng.pending is None

    # Draft should be retained but marked rejected
    exported = repo.export_json()
    assert len(exported["drafts"]) == 1
    assert exported["drafts"][0]["status"] == "rejected"


def test_write_on_confirm(tmp_path: Path) -> None:
    repo = WorkCRMRepo(tmp_path / "wcrm.sqlite")
    eng = WorkCRMEngine(repo)

    r1 = eng.handle('log company=Acme project=Site summary="Call"')
    assert r1.pending is not None

    r2 = eng.handle("记")
    assert r2.wrote
    assert r2.result and "id" in r2.result

    exported = repo.export_json()
    assert len(exported["activities"]) == 1

    # Draft should be retained and committed
    assert len(exported["drafts"]) == 1
    assert exported["drafts"][0]["status"] == "committed"


path: tests/test_crud_digest_export.py
content: from __future__ import annotations

import json
from datetime import datetime, timezone
from pathlib import Path

from workcrm import WorkCRMRepo


def test_schema_crud_basics(tmp_path: Path) -> None:
    repo = WorkCRMRepo(tmp_path / "wcrm.sqlite")
    c = repo.create_company("Acme")
    p = repo.create_project(c["id"], "Website", stage="active")

    companies = repo.list_companies()
    projects = repo.list_projects(company_id=c["id"])

    assert [x["name"] for x in companies] == ["Acme"]
    assert [x["name"] for x in projects] == ["Website"]

    act = repo.log_activity(company_name="Acme", project_name="Website", summary="Kickoff", ts=None, source_text=None)
    task = repo.create_task(company_name="Acme", project_name="Website", title="Send proposal", due_at=None)

    assert act["company_id"] == c["id"]
    assert task["project_id"] == p["id"]


def test_digest_ordering(tmp_path: Path) -> None:
    repo = WorkCRMRepo(tmp_path / "wcrm.sqlite")

    now = datetime(2026, 2, 14, 0, 0, 0, tzinfo=timezone.utc).isoformat()

    repo.create_task(company_name="Acme", project_name="Site", title="Overdue", due_at="2026-02-10T00:00:00+00:00")
    repo.create_task(company_name="Acme", project_name="Site", title="Due today", due_at="2026-02-14T00:00:00+00:00")
    repo.create_task(company_name="Acme", project_name="Site", title="Later", due_at="2026-02-20T00:00:00+00:00")

    digest = repo.followup_digest(now=now)
    assert [t["title"] for t in digest] == ["Overdue", "Due today"]


def test_export_json_valid(tmp_path: Path) -> None:
    repo = WorkCRMRepo(tmp_path / "wcrm.sqlite")
    repo.create_company("Acme")
    data = repo.export_json()
    json.dumps(data)
    for k in [
        "companies",
        "projects",
        "organisations",
        "contacts",
        "activities",
        "tasks",
        "participants",
        "drafts",
    ]:
        assert k in data


path: tests/test_wcrm_003_participants_drafts.py
content: from __future__ import annotations

from pathlib import Path

from workcrm import WorkCRMEngine, WorkCRMRepo


def test_participants_persist_on_confirm(tmp_path: Path) -> None:
    repo = WorkCRMRepo(tmp_path / "wcrm.sqlite")
    eng = WorkCRMEngine(repo)

    r1 = eng.handle(
        'log company=Acme project=Site summary="Call" participants="Alice#c:123,Bob"'
    )
    assert r1.pending is not None

    r2 = eng.handle("记")
    assert r2.wrote

    data = repo.export_json()
    assert len(data["activities"]) == 1
    assert len(data["participants"]) == 2
    labels = [p["label"] for p in data["participants"]]
    assert labels == ["Alice", "Bob"]


def test_list_pending_drafts_deterministic(tmp_path: Path) -> None:
    repo = WorkCRMRepo(tmp_path / "wcrm.sqlite")
    eng = WorkCRMEngine(repo)

    eng.handle('log company=Acme project=Site summary="One"')
    eng.handle('task company=Acme project=Site title="Two" due=today')

    r = eng.handle("草稿")
    assert not r.wrote
    assert r.result and "drafts" in r.result
    drafts = r.result["drafts"]
    assert [d["status"] for d in drafts] == ["pending", "pending"]
    assert [d["kind"] for d in drafts] == ["log", "task"]


path: tests/test_wcrm_004_assignee.py
content: from __future__ import annotations

from pathlib import Path

from workcrm import WorkCRMEngine, WorkCRMRepo


def test_task_assignee_persist_on_confirm(tmp_path: Path) -> None:
    repo = WorkCRMRepo(tmp_path / "wcrm.sqlite")
    eng = WorkCRMEngine(repo)

    r1 = eng.handle('task company=Acme project=Site title="Email" assignee=Alice due=today')
    assert r1.pending is not None

    r2 = eng.handle("记")
    assert r2.wrote
    assert r2.result and "id" in r2.result

    task = repo.get_task(r2.result["id"])
    assert task is not None
    assert task["assignee"] == "Alice"


path: tests/test_wcrm_004_profile_db_path.py
content: from __future__ import annotations

from pathlib import Path

import pytest

from workcrm.profile import resolve_db_path


def test_resolve_db_path_default_work(tmp_path: Path) -> None:
    p = resolve_db_path(None, data_dir=tmp_path)
    assert p.endswith("workcrm_work.sqlite")


def test_resolve_db_path_family(tmp_path: Path) -> None:
    p = resolve_db_path("family", data_dir=tmp_path)
    assert p.endswith("workcrm_family.sqlite")


def test_resolve_db_path_unknown_profile_raises(tmp_path: Path) -> None:
    with pytest.raises(ValueError):
        resolve_db_path("nope", data_dir=tmp_path)


path: tests/test_wcrm_004_profile_routing_engine.py
content: from __future__ import annotations

from dataclasses import dataclass

from workcrm import WorkCRMEngine, WorkCRMRepo


@dataclass(frozen=True)
class StubIntent:
    kind: str
    score: int = 100


def test_family_prefix_routes_to_family_repo(tmp_path, monkeypatch) -> None:
    # Force engine to treat messages as 'log' regardless of content.
    import workcrm.engine as eng_mod

    monkeypatch.setattr(eng_mod, "detect_intent", lambda _t: StubIntent(kind="log"))

    work_repo = WorkCRMRepo(tmp_path / "work.sqlite")
    family_repo = WorkCRMRepo(tmp_path / "family.sqlite")

    eng = WorkCRMEngine({"work": work_repo, "family": family_repo})

    eng.handle('family: log company=Acme project=Site summary="Call"')
    eng.handle("记")

    assert work_repo.export_json()["activities"] == []
    assert len(family_repo.export_json()["activities"]) == 1


def test_chinese_prefix_routes_to_family_repo(tmp_path, monkeypatch) -> None:
    import workcrm.engine as eng_mod

    monkeypatch.setattr(eng_mod, "detect_intent", lambda _t: StubIntent(kind="task"))

    work_repo = WorkCRMRepo(tmp_path / "work.sqlite")
    family_repo = WorkCRMRepo(tmp_path / "family.sqlite")

    eng = WorkCRMEngine({"work": work_repo, "family": family_repo})

    eng.handle('家里: task company=Acme project=Site title="Ping" due=today')
    eng.handle("记")

    assert work_repo.export_json()["tasks"] == []
    assert len(family_repo.export_json()["tasks"]) == 1


path: workcrm/__init__.py
content: """WorkCRM MVP package."""

from .db.repo import WorkCRMRepo
from .engine import WorkCRMEngine
from .profile import resolve_db_path

__all__ = ["WorkCRMRepo", "WorkCRMEngine", "resolve_db_path"]


path: workcrm/db/migrate.py
content: from __future__ import annotations

import sqlite3
from datetime import datetime, timezone
from pathlib import Path


def _utc_now() -> str:
    return datetime.now(timezone.utc).replace(microsecond=0).isoformat()


def ensure_schema(conn: sqlite3.Connection, *, schema_sql: str) -> None:
    conn.execute("PRAGMA foreign_keys = ON")
    conn.executescript(schema_sql)


def get_schema_sql() -> str:
    here = Path(__file__).resolve().parent
    return (here / "schema.sql").read_text(encoding="utf-8")


def apply_migrations(conn: sqlite3.Connection) -> None:
    """Apply schema and incremental migrations in order."""

    ensure_schema(conn, schema_sql=get_schema_sql())

    current = conn.execute("SELECT COALESCE(MAX(version), 0) FROM schema_migrations").fetchone()[0]

    # If this is a brand-new DB, schema.sql just created it. We can mark all known migrations
    # as applied and skip executing them, to avoid duplicate-column errors.
    is_fresh = current == 0

    here = Path(__file__).resolve().parent
    mig_dir = here / "migrations"
    migrations: list[tuple[int, Path]] = []
    for p in mig_dir.glob("*.sql"):
        if not p.name[:4].isdigit():
            continue
        migrations.append((int(p.name[:4]), p))
    migrations.sort(key=lambda x: x[0])

    for version, path in migrations:
        if version <= current:
            continue
        if version == 1:
            # 0001_init.sql uses sqlite3-shell `.read` directive; schema.sql is already applied.
            conn.execute(
                "INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (?, ?)",
                (version, _utc_now()),
            )
            current = version
            continue
        if not is_fresh:
            sql = path.read_text(encoding="utf-8")
            try:
                conn.executescript(sql)
            except sqlite3.DatabaseError as e:
                raise RuntimeError(f"migration failed: {path.name}: {e}")

        conn.execute(
            "INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (?, ?)",
            (version, _utc_now()),
        )
        current = version

    conn.commit()


path: workcrm/db/migrations/0001_init.sql
content: -- version: 1
-- Initialize schema

.read schema.sql


path: workcrm/db/migrations/0002_wcrm_003.sql
content: -- version: 2
-- WCRM-003 schema expansion: organisation/contact enhancements, participants, drafts

PRAGMA foreign_keys = ON;

CREATE TABLE IF NOT EXISTS organisation (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL UNIQUE,
  notes TEXT,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL,
  archived_at TEXT
);

ALTER TABLE contact ADD COLUMN org_id TEXT;
ALTER TABLE contact ADD COLUMN channel_handles TEXT; -- JSON

CREATE TABLE IF NOT EXISTS participant (
  id TEXT PRIMARY KEY,
  parent_kind TEXT NOT NULL, -- 'activity' | 'task'
  parent_id TEXT NOT NULL,
  label TEXT NOT NULL,
  ref_kind TEXT, -- 'contact' | 'organisation'
  ref_id TEXT,
  created_at TEXT NOT NULL,
  UNIQUE(parent_kind, parent_id, label, ref_kind, ref_id)
);

CREATE INDEX IF NOT EXISTS idx_participant_parent ON participant(parent_kind, parent_id, id);

CREATE TABLE IF NOT EXISTS draft (
  id TEXT PRIMARY KEY,
  kind TEXT NOT NULL, -- 'log' | 'task'
  status TEXT NOT NULL, -- 'pending' | 'committed' | 'rejected'
  preview TEXT NOT NULL, -- JSON
  payload TEXT NOT NULL, -- JSON
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_draft_status_created ON draft(status, created_at, id);


path: workcrm/db/migrations/0003_participant_ordinal.sql
content: -- version: 3
-- Add ordinal to participant to preserve deterministic participant ordering.

PRAGMA foreign_keys = ON;

ALTER TABLE participant ADD COLUMN ordinal INTEGER;

-- Backfill existing rows deterministically (by created_at then id) within each parent.
WITH ranked AS (
  SELECT
    id,
    ROW_NUMBER() OVER (PARTITION BY parent_kind, parent_id ORDER BY created_at, id) - 1 AS rn
  FROM participant
)
UPDATE participant
SET ordinal = (SELECT rn FROM ranked WHERE ranked.id = participant.id)
WHERE ordinal IS NULL;

CREATE UNIQUE INDEX IF NOT EXISTS idx_participant_parent_ordinal
  ON participant(parent_kind, parent_id, ordinal);


path: workcrm/db/migrations/0004_task_assignee.sql
content: -- version: 4
-- WCRM-004: add task.assignee (freeform text)

PRAGMA foreign_keys = ON;

ALTER TABLE task ADD COLUMN assignee TEXT;


path: workcrm/db/repo.py
content: from __future__ import annotations

import json
import sqlite3
import uuid
from dataclasses import asdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional

from ..types import ProjectStage
from .migrate import apply_migrations

_ALLOWED_STAGES: set[str] = {"intake", "active", "waiting", "blocked", "paused", "done"}


def _utc_now() -> str:
    return datetime.now(timezone.utc).replace(microsecond=0).isoformat()


class WorkCRMRepo:
    def __init__(self, db_path: str | Path):
        self._db_path = str(db_path)

    def create_organisation(self, name: str, *, notes: str | None = None) -> dict:
        now = _utc_now()
        oid = str(uuid.uuid4())
        with self.connect() as conn:
            conn.execute(
                "INSERT INTO organisation(id,name,notes,created_at,updated_at,archived_at) VALUES (?,?,?,?,?,NULL)",
                (oid, name, notes, now, now),
            )
        return {"id": oid, "name": name, "notes": notes, "created_at": now, "updated_at": now}

    def create_contact(
        self,
        name: str,
        *,
        org_id: str | None = None,
        notes: str | None = None,
        channel_handles: dict | None = None,
    ) -> dict:
        now = _utc_now()
        cid = str(uuid.uuid4())
        ch = json.dumps(channel_handles or {}, sort_keys=True) if channel_handles is not None else None
        with self.connect() as conn:
            conn.execute(
                "INSERT INTO contact(id,company_id,org_id,name,role,email,phone,wechat,channel_handles,notes,created_at,updated_at,archived_at) "
                "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,NULL)",
                (cid, None, org_id, name, None, None, None, None, ch, notes, now, now),
            )
        return {
            "id": cid,
            "org_id": org_id,
            "name": name,
            "channel_handles": json.loads(ch) if ch else {},
            "notes": notes,
            "created_at": now,
            "updated_at": now,
        }

    def _insert_participants(
        self,
        conn: sqlite3.Connection,
        *,
        parent_kind: str,
        parent_id: str,
        participants: list[dict],
    ) -> None:
        now = _utc_now()
        for idx, p in enumerate(participants):
            label = (p.get("label") or "").strip()
            if not label:
                continue
            pid = str(uuid.uuid4())
            ref = p.get("ref") or {}
            ref_kind = ref.get("kind")
            ref_id = ref.get("id")
            conn.execute(
                "INSERT OR IGNORE INTO participant(id,parent_kind,parent_id,ordinal,label,ref_kind,ref_id,created_at) "
                "VALUES (?,?,?,?,?,?,?,?)",
                (pid, parent_kind, parent_id, idx, label, ref_kind, ref_id, now),
            )

    def create_draft(self, *, kind: str, preview: dict, payload: dict) -> dict:
        now = _utc_now()
        did = str(uuid.uuid4())
        with self.connect() as conn:
            conn.execute(
                "INSERT INTO draft(id,kind,status,preview,payload,created_at,updated_at) VALUES (?,?,?,?,?,?,?)",
                (
                    did,
                    kind,
                    "pending",
                    json.dumps(preview, sort_keys=True),
                    json.dumps(payload, sort_keys=True),
                    now,
                    now,
                ),
            )
        return {"id": did, "kind": kind, "status": "pending", "created_at": now, "updated_at": now}

    def set_draft_status(self, draft_id: str, status: str) -> None:
        now = _utc_now()
        with self.connect() as conn:
            conn.execute(
                "UPDATE draft SET status=?, updated_at=? WHERE id=?",
                (status, now, draft_id),
            )

    def list_drafts(self, *, status: str | None = None) -> list[dict]:
        with self.connect() as conn:
            if status:
                rows = conn.execute(
                    "SELECT id,kind,status,created_at,updated_at FROM draft WHERE status=? "
                    "ORDER BY created_at, kind, id",
                    (status,),
                ).fetchall()
            else:
                rows = conn.execute(
                    "SELECT id,kind,status,created_at,updated_at FROM draft ORDER BY created_at, kind, id"
                ).fetchall()
        return [dict(r) for r in rows]

    def get_activity(self, activity_id: str) -> dict | None:
        with self.connect() as conn:
            row = conn.execute("SELECT * FROM activity WHERE id=?", (activity_id,)).fetchone()
            if not row:
                return None
        return dict(row)

    def get_task(self, task_id: str) -> dict | None:
        with self.connect() as conn:
            row = conn.execute("SELECT * FROM task WHERE id=?", (task_id,)).fetchone()
            if not row:
                return None
        return dict(row)

    def connect(self) -> sqlite3.Connection:
        conn = sqlite3.connect(self._db_path)
        conn.row_factory = sqlite3.Row
        conn.execute("PRAGMA foreign_keys = ON")
        apply_migrations(conn)
        return conn

    def create_company(self, name: str, *, domain: str | None = None, notes: str | None = None) -> dict:
        now = _utc_now()
        cid = str(uuid.uuid4())
        with self.connect() as conn:
            conn.execute(
                "INSERT INTO company(id,name,domain,notes,created_at,updated_at,archived_at) VALUES (?,?,?,?,?,?,NULL)",
                (cid, name, domain, notes, now, now),
            )
        return {"id": cid, "name": name, "domain": domain, "notes": notes, "created_at": now, "updated_at": now}

    def list_companies(self) -> list[dict]:
        with self.connect() as conn:
            rows = conn.execute("SELECT * FROM company WHERE archived_at IS NULL ORDER BY created_at, id").fetchall()
        return [dict(r) for r in rows]

    def create_project(
        self,
        company_id: str,
        name: str,
        *,
        stage: ProjectStage = "active",
        notes: str | None = None,
    ) -> dict:
        if stage not in _ALLOWED_STAGES:
            raise ValueError(f"invalid project stage: {stage}")
        now = _utc_now()
        pid = str(uuid.uuid4())
        with self.connect() as conn:
            conn.execute(
                "INSERT INTO project(id,company_id,name,stage,notes,created_at,updated_at,archived_at) VALUES (?,?,?,?,?,?,?,NULL)",
                (pid, company_id, name, stage, notes, now, now),
            )
        return {
            "id": pid,
            "company_id": company_id,
            "name": name,
            "stage": stage,
            "notes": notes,
            "created_at": now,
            "updated_at": now,
        }

    def list_projects(self, *, company_id: Optional[str] = None) -> list[dict]:
        with self.connect() as conn:
            if company_id:
                rows = conn.execute(
                    "SELECT * FROM project WHERE archived_at IS NULL AND company_id=? ORDER BY created_at, id",
                    (company_id,),
                ).fetchall()
            else:
                rows = conn.execute("SELECT * FROM project WHERE archived_at IS NULL ORDER BY created_at, id").fetchall()
        return [dict(r) for r in rows]

    def _get_or_create_company(self, conn: sqlite3.Connection, name: str) -> str:
        row = conn.execute("SELECT id FROM company WHERE name=? AND archived_at IS NULL", (name,)).fetchone()
        if row:
            return row[0]
        now = _utc_now()
        cid = str(uuid.uuid4())
        conn.execute(
            "INSERT INTO company(id,name,domain,notes,created_at,updated_at,archived_at) VALUES (?,?,?,?,?,?,NULL)",
            (cid, name, None, None, now, now),
        )
        return cid

    def _get_or_create_project(self, conn: sqlite3.Connection, company_id: str, name: str) -> str:
        row = conn.execute(
            "SELECT id FROM project WHERE company_id=? AND name=? AND archived_at IS NULL",
            (company_id, name),
        ).fetchone()
        if row:
            return row[0]
        now = _utc_now()
        pid = str(uuid.uuid4())
        conn.execute(
            "INSERT INTO project(id,company_id,name,stage,notes,created_at,updated_at,archived_at) VALUES (?,?,?,?,?,?,?,NULL)",
            (pid, company_id, name, "active", None, now, now),
        )
        return pid

    def log_activity(
        self,
        *,
        company_name: str | None,
        project_name: str | None,
        summary: str,
        ts: str | None,
        source_text: str | None,
        participants: list[dict] | None = None,
    ) -> dict:
        now = _utc_now()
        aid = str(uuid.uuid4())
        with self.connect() as conn:
            company_id = self._get_or_create_company(conn, company_name) if company_name else None
            project_id = None
            if project_name and company_id:
                project_id = self._get_or_create_project(conn, company_id, project_name)
            act_ts = ts or now
            conn.execute(
                "INSERT INTO activity(id,company_id,project_id,contact_id,ts,channel,summary,details,source_text,created_at) "
                "VALUES (?,?,?,?,?,?,?,?,?,?)",
                (aid, company_id, project_id, None, act_ts, "chat", summary, None, source_text, now),
            )
            if participants:
                self._insert_participants(conn, parent_kind="activity", parent_id=aid, participants=participants)
        return {"id": aid, "company_id": company_id, "project_id": project_id, "ts": act_ts, "summary": summary}

    def create_task(
        self,
        *,
        company_name: str | None,
        project_name: str | None,
        title: str,
        due_at: str | None,
        assignee: str | None = None,
        participants: list[dict] | None = None,
    ) -> dict:
        now = _utc_now()
        tid = str(uuid.uuid4())
        with self.connect() as conn:
            company_id = self._get_or_create_company(conn, company_name) if company_name else None
            project_id = None
            if project_name and company_id:
                project_id = self._get_or_create_project(conn, company_id, project_name)
            conn.execute(
                "INSERT INTO task(id,company_id,project_id,contact_id,created_from_activity_id,title,assignee,notes,due_at,status,priority,created_at,updated_at,done_at) "
                "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
                (tid, company_id, project_id, None, None, title, assignee, None, due_at, "open", 0, now, now, None),
            )
            if participants:
                self._insert_participants(conn, parent_kind="task", parent_id=tid, participants=participants)
        return {"id": tid, "company_id": company_id, "project_id": project_id, "title": title, "due_at": due_at}

    def get_project_review(self, project_id: str, *, limit_activities: int = 5) -> dict:
        with self.connect() as conn:
            proj = conn.execute("SELECT * FROM project WHERE id=?", (project_id,)).fetchone()
            if not proj:
                raise KeyError("project not found")
            acts = conn.execute(
                "SELECT * FROM activity WHERE project_id=? ORDER BY ts DESC, id DESC LIMIT ?",
                (project_id, limit_activities),
            ).fetchall()
            tasks = conn.execute(
                "SELECT * FROM task WHERE project_id=? AND status='open' ORDER BY due_at IS NULL, due_at, created_at, id",
                (project_id,),
            ).fetchall()
        return {"project": dict(proj), "recent_activities": [dict(a) for a in acts], "open_tasks": [dict(t) for t in tasks]}

    def followup_digest(self, *, now: str, days_ahead: int = 0) -> list[dict]:
        """Return open tasks due up to cutoff (inclusive). Ordered deterministically."""
        # MVP: treat due_at as ISO8601 date-time string; lexical order matches chronological.
        cutoff = now
        with self.connect() as conn:
            rows = conn.execute(
                "SELECT * FROM task WHERE status='open' AND due_at IS NOT NULL AND due_at<=? "
                "ORDER BY due_at, created_at, id",
                (cutoff,),
            ).fetchall()
        return [dict(r) for r in rows]

    def export_json(self) -> dict[str, Any]:
        with self.connect() as conn:
            def q(table: str) -> list[dict]:
                rows = conn.execute(
                    f"SELECT * FROM {table} ORDER BY created_at, parent_kind, parent_id, ordinal, id"
                    if table == "participant"
                    else f"SELECT * FROM {table} ORDER BY created_at, id"
                ).fetchall()
                return [dict(r) for r in rows]

            data = {
                "schema_version": 2,
                "exported_at": _utc_now(),
                "companies": q("company"),
                "projects": q("project"),
                "organisations": q("organisation"),
                "contacts": q("contact"),
                "activities": q("activity"),
                "tasks": q("task"),
                "participants": q("participant"),
                "drafts": q("draft"),
            }
        # Ensure JSON-serializable
        json.dumps(data, sort_keys=True)
        return data


path: workcrm/db/schema.sql
content: PRAGMA foreign_keys = ON;

CREATE TABLE IF NOT EXISTS schema_migrations (
  version INTEGER PRIMARY KEY,
  applied_at TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS company (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL UNIQUE,
  domain TEXT,
  notes TEXT,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL,
  archived_at TEXT
);

CREATE TABLE IF NOT EXISTS project (
  id TEXT PRIMARY KEY,
  company_id TEXT NOT NULL,
  name TEXT NOT NULL,
  stage TEXT NOT NULL,
  notes TEXT,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL,
  archived_at TEXT,
  UNIQUE(company_id, name),
  FOREIGN KEY(company_id) REFERENCES company(id)
);

CREATE TABLE IF NOT EXISTS organisation (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL UNIQUE,
  notes TEXT,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL,
  archived_at TEXT
);

CREATE TABLE IF NOT EXISTS contact (
  id TEXT PRIMARY KEY,
  company_id TEXT,
  org_id TEXT,
  name TEXT NOT NULL,
  role TEXT,
  email TEXT,
  phone TEXT,
  wechat TEXT,
  channel_handles TEXT,
  notes TEXT,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL,
  archived_at TEXT,
  FOREIGN KEY(company_id) REFERENCES company(id),
  FOREIGN KEY(org_id) REFERENCES organisation(id)
);

CREATE TABLE IF NOT EXISTS activity (
  id TEXT PRIMARY KEY,
  company_id TEXT,
  project_id TEXT,
  contact_id TEXT,
  ts TEXT NOT NULL,
  channel TEXT,
  summary TEXT NOT NULL,
  details TEXT,
  source_text TEXT,
  created_at TEXT NOT NULL,
  FOREIGN KEY(company_id) REFERENCES company(id),
  FOREIGN KEY(project_id) REFERENCES project(id),
  FOREIGN KEY(contact_id) REFERENCES contact(id)
);

CREATE INDEX IF NOT EXISTS idx_activity_ts ON activity(ts);
CREATE INDEX IF NOT EXISTS idx_activity_company_ts ON activity(company_id, ts);
CREATE INDEX IF NOT EXISTS idx_activity_project_ts ON activity(project_id, ts);

CREATE TABLE IF NOT EXISTS task (
  id TEXT PRIMARY KEY,
  company_id TEXT,
  project_id TEXT,
  contact_id TEXT,
  created_from_activity_id TEXT,
  title TEXT NOT NULL,
  assignee TEXT,
  notes TEXT,
  due_at TEXT,
  status TEXT NOT NULL,
  priority INTEGER NOT NULL,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL,
  done_at TEXT,
  FOREIGN KEY(company_id) REFERENCES company(id),
  FOREIGN KEY(project_id) REFERENCES project(id),
  FOREIGN KEY(contact_id) REFERENCES contact(id),
  FOREIGN KEY(created_from_activity_id) REFERENCES activity(id)
);

CREATE INDEX IF NOT EXISTS idx_task_status_due ON task(status, due_at);
CREATE INDEX IF NOT EXISTS idx_task_company_status_due ON task(company_id, status, due_at);
CREATE INDEX IF NOT EXISTS idx_task_project_status_due ON task(project_id, status, due_at);

CREATE TABLE IF NOT EXISTS participant (
  id TEXT PRIMARY KEY,
  parent_kind TEXT NOT NULL,
  parent_id TEXT NOT NULL,
  ordinal INTEGER NOT NULL,
  label TEXT NOT NULL,
  ref_kind TEXT,
  ref_id TEXT,
  created_at TEXT NOT NULL,
  UNIQUE(parent_kind, parent_id, ordinal),
  UNIQUE(parent_kind, parent_id, label, ref_kind, ref_id)
);

CREATE INDEX IF NOT EXISTS idx_participant_parent ON participant(parent_kind, parent_id, id);

CREATE TABLE IF NOT EXISTS draft (
  id TEXT PRIMARY KEY,
  kind TEXT NOT NULL,
  status TEXT NOT NULL,
  preview TEXT NOT NULL,
  payload TEXT NOT NULL,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_draft_status_created ON draft(status, created_at, id);


path: workcrm/engine.py
content: from __future__ import annotations

from dataclasses import asdict
from datetime import datetime, timezone
from typing import Optional

from .intent import detect_intent
from .parse import parse_freeform
from .types import EngineResponse, ProposedAction


_FAMILY_PREFIXES = ("family:", "家里:", "个人:")


class WorkCRMEngine:
    """Pure logic engine for intent -> propose -> confirm -> write."""

    def __init__(self, repo):
        # repo can be a single repo (work only) or a mapping: {"work": repo, "family": repo}
        self._repo = repo
        self._pending: Optional[ProposedAction] = None

    @property
    def pending(self) -> Optional[ProposedAction]:
        return self._pending

    def _select_repo_and_text(self, text: str):
        t = (text or "").strip()
        if isinstance(self._repo, dict):
            tl = t.lower()
            for p in _FAMILY_PREFIXES:
                if tl.startswith(p):
                    return self._repo["family"], t[len(p) :].lstrip()
            return self._repo["work"], t
        return self._repo, t

    def handle(self, text: str, *, now: Optional[datetime] = None) -> EngineResponse:
        repo, t = self._select_repo_and_text(text)

        if t in {"记", "不记"}:
            return self._handle_confirm(t)

        if t in {"草稿", "drafts", "待确认"}:
            rows = repo.list_drafts(status="pending")
            return EngineResponse(message=_fmt_pending_drafts(rows), wrote=False, result={"drafts": rows})

        intent = detect_intent(t)
        if intent.kind in {"log", "task"}:
            parsed = parse_freeform(t, now=now)
            preview = {
                "kind": intent.kind,
                "company": parsed.company,
                "project": parsed.project,
                "summary": parsed.summary if intent.kind == "log" else None,
                "title": parsed.title if intent.kind == "task" else None,
                "due_at": parsed.due_at,
                "assignee": parsed.assignee if intent.kind == "task" else None,
            }
            payload = {"intent": intent.kind, **asdict(parsed)}
            if isinstance(self._repo, dict):
                payload["_profile"] = "family" if repo is self._repo["family"] else "work"
            if payload.get("participants"):
                preview["participants"] = payload["participants"]
            draft = repo.create_draft(kind=intent.kind, preview=preview, payload=payload)
            self._pending = ProposedAction(
                kind=intent.kind,
                preview=preview,
                payload=payload,
                participants=payload.get("participants"),
                draft_id=draft["id"],
            )
            return EngineResponse(
                message=(
                    f"Draft {draft['id']} pending. Proposed {intent.kind}: {preview}. "
                    "Reply `记` to write, or `不记` to reject."
                ),
                pending=self._pending,
                wrote=False,
            )

        if intent.kind == "query":
            return EngineResponse(message="Query intent detected (MVP). No write performed.", wrote=False)

        return EngineResponse(message="No clear intent detected (MVP).", wrote=False)

    def _handle_confirm(self, token: str) -> EngineResponse:
        if not self._pending:
            return EngineResponse(message="Nothing pending.", wrote=False)

        if token == "不记":
            pa = self._pending
            self._pending = None
            r = self._repo
            if isinstance(r, dict):
                prof = pa.payload.get("_profile") or "work"
                r = r[prof]
            if pa.draft_id:
                r.set_draft_status(pa.draft_id, "rejected")
            return EngineResponse(message="Rejected draft.", wrote=False)

        assert token == "记"
        pa = self._pending
        self._pending = None

        r = self._repo
        if isinstance(r, dict):
            prof = pa.payload.get("_profile") or "work"
            r = r[prof]

        if pa.kind == "log":
            res = r.log_activity(
                company_name=pa.payload.get("company"),
                project_name=pa.payload.get("project"),
                summary=pa.payload.get("summary") or "",
                ts=now_utc(),
                source_text=None,
                participants=pa.payload.get("participants"),
            )
            if pa.draft_id:
                r.set_draft_status(pa.draft_id, "committed")
            return EngineResponse(message=f"Written activity {res['id']}.", wrote=True, result=res)

        if pa.kind == "task":
            res = r.create_task(
                company_name=pa.payload.get("company"),
                project_name=pa.payload.get("project"),
                title=pa.payload.get("title") or "",
                due_at=pa.payload.get("due_at"),
                assignee=pa.payload.get("assignee"),
                participants=pa.payload.get("participants"),
            )
            if pa.draft_id:
                r.set_draft_status(pa.draft_id, "committed")
            return EngineResponse(message=f"Written task {res['id']}.", wrote=True, result=res)

        return EngineResponse(message="Unknown pending action.", wrote=False)


def now_utc() -> str:
    return datetime.now(timezone.utc).replace(microsecond=0).isoformat()


def _fmt_pending_drafts(rows: list[dict]) -> str:
    if not rows:
        return "No pending drafts."
    parts: list[str] = ["Pending drafts:"]
    for r in rows:
        parts.append(f"- {r['id']} {r['kind']} created_at={r['created_at']}")
    return "\n".join(parts)


path: workcrm/intent.py
content: from __future__ import annotations

import re
from dataclasses import dataclass
from typing import Literal

IntentKind = Literal["log", "task", "query", "unknown"]


@dataclass(frozen=True)
class IntentResult:
    kind: IntentKind
    score: int


_LOG_KWS = [
    "met",
    "meeting",
    "call",
    "called",
    "spoke",
    "emailed",
    "email",
    "sent",
    "回复",
    "聊了",
    "通话",
    "见了",
]
_TASK_KWS = [
    "remind",
    "need to",
    "should",
    "follow up",
    "todo",
    "催",
    "提醒",
    "跟进",
    "下次",
    "需要",
]
_QUERY_KWS = [
    "show",
    "status",
    "digest",
    "what's next",
    "whats next",
    "who do i need",
    "还有什么",
    "进展",
]


def detect_intent(text: str) -> IntentResult:
    t = (text or "").strip().lower()
    if not t:
        return IntentResult(kind="unknown", score=0)

    # Explicit commands win.
    if re.match(r"^(log|task|show|followup|digest|export)\b", t):
        cmd = t.split(None, 1)[0]
        if cmd == "log":
            return IntentResult("log", 100)
        if cmd == "task":
            return IntentResult("task", 100)
        return IntentResult("query", 100)

    def score_for(kws: list[str]) -> int:
        s = 0
        for kw in kws:
            if kw in t:
                s += 10
        return s

    log_s = score_for(_LOG_KWS)
    task_s = score_for(_TASK_KWS)
    query_s = score_for(_QUERY_KWS)

    best = max([(log_s, "log"), (task_s, "task"), (query_s, "query")], key=lambda x: x[0])
    if best[0] == 0:
        return IntentResult("unknown", 0)
    return IntentResult(best[1], best[0])


path: workcrm/parse.py
content: from __future__ import annotations

import re
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone
from typing import Optional


@dataclass(frozen=True)
class ParsedMessage:
    company: Optional[str]
    project: Optional[str]
    summary: Optional[str]
    title: Optional[str]
    due_at: Optional[str]
    assignee: Optional[str]
    participants: list[dict]


def _iso_now() -> str:
    return datetime.now(timezone.utc).replace(microsecond=0).isoformat()


def parse_due_at(token: str, *, now: Optional[datetime] = None) -> Optional[str]:
    if not [REDACTED_SECRET] None
    now_dt = now or datetime.now(timezone.utc)
    t = token.strip().lower()

    if t in {"today", "tod"}:
        d = now_dt.date()
        return datetime(d.year, d.month, d.day, tzinfo=timezone.utc).isoformat()
    if t == "tomorrow":
        d = now_dt.date() + timedelta(days=1)
        return datetime(d.year, d.month, d.day, tzinfo=timezone.utc).isoformat()

    m = re.fullmatch(r"(\d+)(d)", t)
    if m:
        days = int(m.group(1))
        d = now_dt.date() + timedelta(days=days)
        return datetime(d.year, d.month, d.day, tzinfo=timezone.utc).isoformat()

    # YYYY-MM-DD
    try:
        d = date.fromisoformat(t)
        return datetime(d.year, d.month, d.day, tzinfo=timezone.utc).isoformat()
    except ValueError:
        return None


def _parse_participants(t: str) -> list[dict]:
    m = re.search(r"\bparticipants=([^\n]+)", t)
    if not m:
        return []
    raw = m.group(1).strip()
    if raw.startswith('"') and raw.endswith('"'):
        raw = raw[1:-1]
    if not raw:
        return []

    out: list[dict] = []
    for item in [x.strip() for x in raw.split(",") if x.strip()]:
        if "#" in item:
            label, ref = item.split("#", 1)
            label = label.strip()
            ref = ref.strip()
            if ref.startswith("c:"):
                out.append({"label": label or ref, "ref": {"kind": "contact", "id": ref[2:]}})
            elif ref.startswith("o:"):
                out.append({"label": label or ref, "ref": {"kind": "organisation", "id": ref[2:]}})
            else:
                out.append({"label": item})
        else:
            out.append({"label": item})
    return out


def parse_freeform(text: str, *, now: Optional[datetime] = None) -> ParsedMessage:
    t = (text or "").strip()

    company = None
    project = None
    participants = _parse_participants(t)
    # Pattern: Company/Project
    m = re.search(r"([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)", t)
    if m:
        company = m.group(1)
        project = m.group(2)

    # key=value tokens (very small MVP)
    def get_kv(key: str) -> Optional[str]:
        km = re.search(rf"\b{re.escape(key)}=([^\s]+)", t)
        return km.group(1) if km else None

    company = get_kv("company") or company
    project = get_kv("project") or project

    summary = None
    title = None

    sm = re.search(r"\bsummary=\"([^\"]+)\"", t)
    if sm:
        summary = sm.group(1)

    tm = re.search(r"\btitle=\"([^\"]+)\"", t)
    if tm:
        title = tm.group(1)

    due_tok = get_kv("due")
    due_at = parse_due_at(due_tok, now=now) if due_tok else None

    am = re.search(r"\bassignee=([^\s]+)", t)
    assignee = am.group(1) if am else None

    if not summary and not title:
        # fallback: use full text
        summary = t[:280] if t else None
        title = summary

    return ParsedMessage(
        company=company,
        project=project,
        summary=summary,
        title=title,
        due_at=due_at,
        assignee=assignee,
        participants=participants,
    )


__all__ = ["ParsedMessage", "parse_freeform", "parse_due_at", "_iso_now"]


path: workcrm/profile.py
content: from __future__ import annotations

from pathlib import Path


def resolve_db_path(profile: str | None, *, data_dir: str | Path | None = None) -> str:
    """Resolve sqlite DB path for a given profile.

    Profiles:
    - work (default)
    - family

    data_dir defaults to ~/.openclaw/data/workcrm
    """

    p = (profile or "work").strip().lower() or "work"
    if p not in {"work", "family"}:
        raise ValueError(f"unknown profile: {profile}")

    base = Path(data_dir).expanduser() if data_dir is not None else Path.home() / ".openclaw" / "data" / "workcrm"
    base.mkdir(parents=True, exist_ok=True)

    fname = "workcrm_work.sqlite" if p == "work" else "workcrm_family.sqlite"
    return str(base / fname)


path: workcrm/types.py
content: from __future__ import annotations

from dataclasses import dataclass
from typing import Literal, Optional

DraftStatus = Literal["pending", "committed", "rejected"]
ParticipantRefKind = Literal["contact", "organisation"]

ProjectStage = Literal["intake", "active", "waiting", "blocked", "paused", "done"]
TaskStatus = Literal["open", "done", "canceled"]


@dataclass(frozen=True)
class ProposedAction:
    kind: Literal["log", "task", "query"]
    preview: dict
    payload: dict
    participants: list[dict] | None = None
    draft_id: str | None = None


@dataclass(frozen=True)
class EngineResponse:
    message: str
    pending: Optional[ProposedAction] = None
    wrote: bool = False
    result: Optional[dict] = None


path: workcrm.egg-info/SOURCES.txt
content: README.md
pyproject.toml
tests/test_confirm_gate.py
tests/test_crud_digest_export.py
tests/test_wcrm_003_participants_drafts.py
tests/test_wcrm_004_assignee.py
tests/test_wcrm_004_profile_db_path.py
tests/test_wcrm_004_profile_routing_engine.py
workcrm/__init__.py
workcrm/engine.py
workcrm/intent.py
workcrm/parse.py
workcrm/profile.py
workcrm/types.py
workcrm.egg-info/PKG-INFO
workcrm.egg-info/SOURCES.txt
workcrm.egg-info/dependency_links.txt
workcrm.egg-info/top_level.txt
workcrm/db/migrate.py
workcrm/db/repo.py

path: workcrm.egg-info/dependency_links.txt
content: 


path: workcrm.egg-info/top_level.txt
content: workcrm


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)