Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ inventory_summary()

| | |
|---|---|
| **Tools** | `inventory_summary` · `inventory_list` · `inventory_get` · `inventory_upsert_lot` · `inventory_upsert_item` · `inventory_delete_item` · `inventory_set_price` · `inventory_mark_sold` · `inventory_listing` · `inventory_import_csv` · `inventory_export_csv` · `inventory_stale` |
| **Tools** | `inventory_summary` · `inventory_list` · `inventory_get` · `inventory_upsert_lot` · `inventory_upsert_item` · `inventory_delete_item` · `inventory_set_price` · `inventory_mark_sold` · `inventory_listing` · `inventory_import_csv` · `inventory_export_csv` · `inventory_reprice_plan` · `inventory_stale` |
| **View** | a rail panel: the item grid (double-click to edit name/category/condition/qty; status select; click a price for the Price dialog), Price / Sold / Listing / Edit / Delete per row, lots with their P&L, sales, an activity log, CSV import (file or paste, with the mapping report) and export |
| **API** | bearer-gated JSON under `/api/plugins/inventory` — `summary`, `lots`, `items`, `items/{id}/price`, `items/{id}/sold`, `items/{id}/listings`, `listings/{id}/end`, `sales`, `stale`, `audit`, `import`, `export` |
| **Automations** | `weekly_review: true` arms a plugin-owned recurring turn (`weekly_review_cron`, default Monday 09:00 in `review_timezone`) that re-prices stale evidence from eBay sold comps through the same tools, reports stale listings with a recommendation (it never changes a listing itself), and posts the per-lot P&L. Cancelled when the plugin is disabled. |
| **Events** | `inventory.item.changed`, `inventory.lot.changed`, `inventory.sale.recorded`, `inventory.imported` |
| **Skill** | `inventory-ops` — the rules (a target needs a basis; never set sold by hand; sold ≠ active ≠ retail) and the re-price / weekly-review routines |

Expand Down
11 changes: 11 additions & 0 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,15 @@ def emit(topic: str, data: dict) -> None:
except Exception: # noqa: BLE001
log.exception("[inventory] registering skills failed")

# Automations (slice 3): a plugin-owned weekly review turn on the host scheduler. Armed
# here so a config reload re-arms it and a disable sweeps it (#1642). Best-effort: a
# host without the scheduler seam just logs.
try:
from .automations import arm
from .tools import as_bool

arm(registry, cfg, plugin_id, enabled=as_bool(cfg.get("weekly_review"), False))
except Exception: # noqa: BLE001
log.debug("[inventory] automations not armed (no scheduler seam on this host?)", exc_info=True)

log.info("[inventory] registered")
97 changes: 97 additions & 0 deletions automations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Slice 3 — the automations: a plugin-owned weekly review job and the re-price plan the
agent (or that job) works through. Nothing here touches a listing or a price by itself;
the job is an agent TURN that uses the same tools an operator would, with the same rules.
"""

from __future__ import annotations

import logging

from .store import InventoryStore

log = logging.getLogger("protoagent.plugins.inventory")

JOB_ID = "weekly-review"
DEFAULT_CRON = "0 9 * * 1" # Monday 09:00

WEEKLY_REVIEW_PROMPT = """Weekly inventory review (automated; owned by the inventory plugin). Load the inventory-ops skill and the ebay-pricing skill first, then:

1. Call inventory_stale(listed_days={listed_days}, price_days={price_days}).
2. Stale PRICES — at most {max_items} items this run, oldest evidence first: for each, run ebay_price_check (sold) with a buyer-style query (inventory_reprice_plan suggests one). Read notes / headline_count / results_found before the statistics. With at least 5 exact sold comps, call inventory_set_price with low=p25, target=median, high=p75, basis "eBay sold comps (N sold, incl. shipping)" and the evidence fields (source=ebay_sold, n, p25, median, p75, query). With fewer, keep the targets and call inventory_set_price with the existing numbers and a basis noting "sold comps thin (N)" so the attempt is on record. If eBay answers with a sign-in wall or a challenge page, stop the eBay calls and say so.
3. Stale LISTINGS — for each, report the channel, days live, listed price vs the current target, and recommend one of: drop the price, relist, bundle, withdraw. Do NOT change or end a listing yourself; that is the operator's call.
4. Finish with inventory_summary and report per lot: cost, remaining at target (with the low–high band), realized net, projected net at target. Every number carries its basis; say once that fees are not yet netted out of remaining value.

Keep the whole report short enough to read in a minute."""


def weekly_review_prompt(cfg: dict) -> str:
return WEEKLY_REVIEW_PROMPT.format(
listed_days=int(cfg.get("stale_listing_days") or 14),
price_days=int(cfg.get("stale_price_days") or 30),
max_items=int(cfg.get("review_max_items") or 15),
)


def arm(registry, cfg: dict, plugin_id: str, *, enabled: bool) -> dict:
"""Arm (or disarm) the weekly review job through the host scheduler. Idempotent by
job id, so a config reload re-arms cleanly; a disable cancels. Returns what happened."""
from graph import sdk # host-only; absent in the test suite unless stubbed

if not enabled:
removed = sdk.cancel_scheduled(JOB_ID, plugin_id=plugin_id)
return {"armed": False, "cancelled": bool(removed)}
cron = str(cfg.get("weekly_review_cron") or DEFAULT_CRON).strip()
tz = str(cfg.get("review_timezone") or "").strip() or None
res = sdk.schedule_recurring(weekly_review_prompt(cfg), cron, plugin_id=plugin_id, job_id=JOB_ID, timezone=tz)
if not res.get("ok"):
log.warning("[inventory] weekly review not scheduled: %s", res.get("message"))
else:
log.info("[inventory] weekly review armed: %s (%s) next %s", cron, tz or "UTC", res.get("next_fire"))
return {
"armed": bool(res.get("ok")),
"cron": cron,
"timezone": tz or "UTC",
"next_fire": res.get("next_fire"),
"message": res.get("message"),
}


_CONDITION_WORDS = ("sealed", "new on sprue", "on sprue", "nib", "new", "unpainted", "painted", "assembled")


def suggest_query(item: dict) -> str:
"""A buyer-style eBay query for an item: the name, plus the condition words that change
the price, minus the operator's own shorthand (ids, "x10", parenthetical notes)."""
name = str(item.get("name") or "").strip()
cond = str(item.get("condition") or "").strip().lower()
words = [w for w in _CONDITION_WORDS if w in cond and w not in name.lower()]
# keep the most specific condition phrase only ("new on sprue" already implies "new")
if "new on sprue" in words and "new" in words:
words.remove("new")
if "new on sprue" in words and "on sprue" in words:
words.remove("on sprue")
q = " ".join([name, *words]).strip()
return q


def reprice_plan(store: InventoryStore, *, lot_id: str = "", max_items: int = 15, price_days: int = 30) -> dict:
"""The unsold items whose price evidence is oldest (or missing), capped, each with a
suggested query and its current band — the worklist a re-price loops over."""
stale = store.stale(listed_days=10_000, price_days=price_days)["stale_prices"]
if lot_id:
stale = [i for i in stale if i["lot_id"] == lot_id]
stale.sort(key=lambda i: (i["price_updated_on"] or "", i["id"]))
plan = [
{
"item_id": i["id"],
"name": i["name"],
"lot_id": i["lot_id"],
"status": i["status"],
"query": suggest_query(i),
"current": {"low": i["target_low"], "target": i["target"], "high": i["target_high"]},
"price_basis": i["price_basis"],
"price_updated_on": i["price_updated_on"],
}
for i in stale[: max(1, int(max_items))]
]
return {"items": plan, "total_stale": len(stale), "price_days": price_days}
22 changes: 19 additions & 3 deletions protoagent.plugin.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
id: inventory
name: Inventory (resale source of truth)
version: 0.2.0
version: 0.3.0
description: >-
The resale inventory as a source of truth: lots (what you bought and for how much),
items (what each lot split into, its condition and status), listings (where each item is
Expand All @@ -9,8 +9,8 @@ description: >-
audit trail of every change and who made it.

Contributes agent tools for every operation (summary/P&L, list, get, upsert lot/item,
set price with evidence, mark sold, listings, CSV import/export, stale review), a
console rail view (a grid with inline edits, price / sold / listing dialogs, CSV
set price with evidence, mark sold, listings, CSV import/export, stale review, re-price
plan), an optional weekly-review job on the host scheduler, a console rail view (a grid with inline edits, price / sold / listing dialogs, CSV
import/export, lots, sales and an activity log) and a bearer-gated JSON API under
/api/plugins/inventory. Money is stored as integer cents and exposed as dollars. Ships
DISABLED; enable with `plugins: { enabled: [inventory] }`.
Expand All @@ -27,6 +27,16 @@ config:
# Directory that relative paths in inventory_import_csv / inventory_export_csv resolve
# against. Blank = the process working directory; set it to the agent workspace.
workspace_dir: ""
# ── Automations ── a plugin-owned recurring agent turn on the host scheduler (cancelled
# when the plugin is disabled). The weekly review: re-prices stale evidence from eBay
# sold comps (through the same tools, same rules), reports stale listings with a
# recommendation (never changes a listing itself), and ends with the per-lot P&L.
weekly_review: false
weekly_review_cron: "0 9 * * 1" # Monday 09:00 in review_timezone
review_timezone: "" # IANA name, e.g. America/Los_Angeles; blank = UTC
review_max_items: 15 # re-price at most this many items per run (eBay pacing)
stale_listing_days: 14 # a live listing older than this is reported
stale_price_days: 30 # price evidence older than this is re-checked

# Console view (ADR 0026): a left-rail icon whose panel iframes the page this plugin
# serves at `path` (public prefix); its data comes through /api/plugins/inventory (gated).
Expand All @@ -36,6 +46,12 @@ views:
# Editable in Settings ▸ Plugins (ADR 0019).
settings:
- { key: workspace_dir, label: "Workspace directory", type: path, path_kind: dir, description: "Relative paths given to inventory_import_csv / inventory_export_csv resolve here. Point it at the agent workspace." }
- { key: weekly_review, label: "Weekly review", type: bool, description: "Arm a recurring agent turn that re-prices stale evidence from eBay sold comps, reports stale listings with a recommendation (it never changes a listing itself), and posts the per-lot P&L." }
- { key: weekly_review_cron, label: "Weekly review cron", type: string, description: "5-field cron, evaluated in the review timezone. Default Monday 09:00." }
- { key: review_timezone, label: "Review timezone", type: string, description: "IANA name such as America/Los_Angeles. Blank = UTC." }
- { key: review_max_items, label: "Items re-priced per run", type: number, description: "Caps eBay searches per review run; the rest wait for the next one." }
- { key: stale_listing_days, label: "Stale listing (days)", type: number, description: "A live listing older than this is reported in the review." }
- { key: stale_price_days, label: "Stale price (days)", type: number, description: "Price evidence older than this is re-checked in the review." }

# Event bus (ADR 0039): the topics this plugin publishes.
emits:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "inventory-plugin"
version = "0.2.0"
version = "0.3.0"
description = "Resale inventory as a source of truth for protoAgent — lots, items, listings, sales, price evidence, audit"
requires-python = ">=3.11"

Expand Down
10 changes: 8 additions & 2 deletions skills/inventory-ops/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,14 @@ not a note. If a number is not in the inventory, it has not been decided.
## Weekly review

`inventory_stale` lists listings older than 14 days and items whose price evidence is
older than 30 days. Re-price the stale prices, ask the operator about the stale listings
(drop the price, relist, bundle, or withdraw), and finish with `inventory_summary`.
older than 30 days. `inventory_reprice_plan` turns the stale prices into a worklist
(oldest evidence first, capped, each with a suggested buyer-style query). Re-price them,
ask the operator about the stale listings (drop the price, relist, bundle, or withdraw —
never end a listing yourself), and finish with `inventory_summary`.

When `weekly_review: true` is set in the plugin config, the host scheduler runs exactly
this routine as an automated turn on `weekly_review_cron` (default Monday 09:00). It uses
the same tools and the same rules; the only thing it never does is touch a listing.

## Importing a spreadsheet

Expand Down
135 changes: 135 additions & 0 deletions tests/test_automations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Slice 3: the weekly review job is armed/disarmed through the host scheduler seam, the
prompt drives the same tools, and the re-price plan orders and caps the worklist."""

from __future__ import annotations

import json
import sys
import types

import inventory_plugin
import pytest
import yaml
from inventory_plugin.automations import DEFAULT_CRON, JOB_ID, reprice_plan, suggest_query, weekly_review_prompt


@pytest.fixture
def fake_sdk(monkeypatch):
"""A stand-in for the host's `graph.sdk` — records schedule/cancel calls."""
calls = []
sdk = types.SimpleNamespace(
schedule_recurring=lambda prompt, cron, *, plugin_id, job_id, session="", timezone=None: (
calls.append(("schedule", prompt, cron, plugin_id, job_id, timezone))
or {
"ok": True,
"job_id": f"plugin:{plugin_id}:{job_id}",
"next_fire": "2026-09-14T09:00:00",
"message": "ok",
}
),
cancel_scheduled=lambda job_id, *, plugin_id: calls.append(("cancel", job_id, plugin_id)) or True,
)
graph = types.ModuleType("graph")
graph.sdk = sdk
monkeypatch.setitem(sys.modules, "graph", graph)
monkeypatch.setitem(sys.modules, "graph.sdk", sdk)
return calls


class TestArming:
def test_enabled_arms_the_job_with_the_configured_cadence(self, registry, fake_sdk):
registry.config.update(
{
"weekly_review": True,
"weekly_review_cron": "30 8 * * 2",
"review_timezone": "America/Los_Angeles",
"review_max_items": 7,
}
)
inventory_plugin.register(registry)
sched = [c for c in fake_sdk if c[0] == "schedule"]
assert len(sched) == 1
_, prompt, cron, plugin_id, job_id, tz = sched[0]
assert (cron, plugin_id, job_id, tz) == ("30 8 * * 2", "inventory", JOB_ID, "America/Los_Angeles")
for tool in (
"inventory_stale",
"inventory_reprice_plan",
"ebay_price_check",
"inventory_set_price",
"inventory_summary",
):
assert tool in prompt
assert "at most 7 items" in prompt
assert "Do NOT change or end a listing" in prompt

def test_disabled_cancels_the_job(self, registry, fake_sdk):
registry.config.update({"weekly_review": "false"})
inventory_plugin.register(registry)
assert [c for c in fake_sdk if c[0] == "schedule"] == []
assert ("cancel", JOB_ID, "inventory") in fake_sdk

def test_default_is_off_and_default_cron_is_monday_morning(self, registry, fake_sdk):
inventory_plugin.register(registry)
assert [c for c in fake_sdk if c[0] == "schedule"] == []
assert DEFAULT_CRON == "0 9 * * 1"

def test_no_scheduler_seam_does_not_break_registration(self, registry, monkeypatch):
monkeypatch.setitem(sys.modules, "graph", None) # `from graph import sdk` raises ImportError
registry.config.update({"weekly_review": True})
inventory_plugin.register(registry) # must not raise
assert registry.tools

def test_the_manifest_ships_the_review_off_with_settings(self):
from pathlib import Path

manifest = yaml.safe_load((Path(inventory_plugin.__file__).parent / "protoagent.plugin.yaml").read_text())
assert manifest["config"]["weekly_review"] is False
assert manifest["config"]["weekly_review_cron"] == DEFAULT_CRON
assert {s["key"] for s in manifest["settings"]} >= {
"weekly_review",
"weekly_review_cron",
"review_timezone",
"stale_price_days",
}


class TestReplan:
def test_suggest_query_keeps_price_changing_condition_words_only(self):
assert (
suggest_query({"name": "Brionne Barons Bretonnian Team", "condition": "new on sprue"})
== "Brionne Barons Bretonnian Team new on sprue"
)
assert (
suggest_query({"name": "Third Season box", "condition": "Sealed (shrink torn)"})
== "Third Season box sealed"
)
assert suggest_query({"name": "Sealed Second Season box", "condition": "sealed"}) == "Sealed Second Season box"
assert suggest_query({"name": "Dice", "condition": ""}) == "Dice"

def test_plan_orders_oldest_evidence_first_and_caps(self, store):
store.upsert_lot({"id": "L", "name": "L"}, actor="t")
for i, (iid, when) in enumerate((("A", "2026-09-01"), ("B", ""), ("C", "2025-01-01"), ("D", "2026-09-10"))):
store.upsert_item({"id": iid, "name": f"Item {iid}", "lot_id": "L"}, actor="t")
if when:
store.set_price(iid, target=10 + i, basis="b", observed_on=when, actor="t")
store.upsert_item({"id": "S", "name": "Sold", "lot_id": "L"}, actor="t")
store.mark_sold("S", price=5, channel="x", actor="t")
plan = reprice_plan(store, price_days=30, max_items=2)
assert [p["item_id"] for p in plan["items"]] == ["B", "C"] # missing evidence first, then oldest
assert plan["total_stale"] == 2 # A (11 days) and D are fresh; S is sold; B has no evidence, C is ancient
assert plan["items"][1]["current"]["target"] == 12.0
assert plan["items"][0]["query"] == "Item B"

def test_plan_tool_and_lot_filter(self, registry):
inventory_plugin.register(registry)
t = {x.name: x for x in registry.tools}
for lot in ("L1", "L2"):
t["inventory_upsert_lot"].invoke({"id": lot, "name": lot})
t["inventory_upsert_item"].invoke({"id": "X", "name": "Thing", "lot_id": "L1"})
t["inventory_upsert_item"].invoke({"id": "Y", "name": "Other", "lot_id": "L2"})
out = json.loads(t["inventory_reprice_plan"].invoke({"lot_id": "L1"}))
assert out["ok"] and [p["item_id"] for p in out["items"]] == ["X"]

def test_prompt_uses_the_configured_thresholds(self):
p = weekly_review_prompt({"stale_listing_days": 7, "stale_price_days": 21, "review_max_items": 3})
assert "listed_days=7" in p and "price_days=21" in p and "at most 3 items" in p
2 changes: 1 addition & 1 deletion tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def test_register_contributes_tools_router_skills(registry):
"inventory_mark_sold",
"inventory_import_csv",
} <= names
assert len(names) == 12
assert len(names) == 13
assert {prefix for _, prefix in registry.routers} == {"/api/plugins/inventory", "/plugins/inventory"}
assert "skills" in registry.skill_dirs
for t in registry.tools:
Expand Down
Loading
Loading