code.pablomurad.com

prami-pet

Community virtual pet that lives as a Mastodon account

first commit
author
Pablo Murad
date
commit
f5312eea0b97f87454797a5f4da543598ee31845
changes
81 files +6567 −0
@@ -0,0 +1,11 @@+__pycache__/
+*.py[cod]
+.venv/
+venv/
+.env
+*.db
+*.sqlite3
+.pytest_cache/
+.git/
+.gitignore
+tests/
@@ -0,0 +1,75 @@+# --- Mastodon connection ---
+MASTODON_BASE_URL=https://your.instance.social
+MASTODON_ACCESS_TOKEN=your-access-token-here
+BOT_ACCOUNT_ACCT=prami@your.instance.social
+
+# --- Database ---
+# Postgres (default for docker compose):
+DATABASE_URL=postgresql+psycopg2://prami:prami@db:5432/prami
+# Local SQLite alternative (no postgres needed):
+# DATABASE_URL=sqlite:///prami.db
+
+# --- Timing ---
+POLL_INTERVAL_SECONDS=30
+STATE_DECAY_INTERVAL_MINUTES=15
+AUTOPOST_INTERVAL_MINUTES=240
+
+# --- Posting behaviour ---
+DEFAULT_VISIBILITY=unlisted
+AUTOPOST_VISIBILITY=unlisted
+AUTOPOST_ENABLED=true
+
+# --- Pet identity ---
+PET_NAME=Prami
+PET_SPECIES=community critter
+# Traits here bias which response templates Prami reaches for. Recognised words include:
+# weird, affectionate, dramatic, suspicious, chaotic, calm, shy, clingy, grumpy, brave.
+PET_PERSONALITY=weird, affectionate, slightly dramatic
+# Template pack to use. Only "default" ships today; this is the hook for future packs.
+PET_PERSONALITY_PACK=default
+
+# --- Anti-abuse ---
+MAX_COMMANDS_PER_USER_PER_HOUR=20
+MAX_COMMANDS_GLOBAL_PER_HOUR=200
+# Comma-separated accts to ignore, e.g. spammer@bad.social,troll@elsewhere.social
+BLOCKED_USERS=
+
+# --- Maintenance ---
+# Processed status IDs older than this are pruned daily to keep the table small.
+PROCESSED_RETENTION_DAYS=14
+
+# --- Social: favourites (off unless enabled) ---
+ENABLE_FAVOURITES=false
+MAX_FAVOURITES_PER_HOUR=8
+MAX_FAVOURITES_PER_USER_PER_DAY=3
+# Favouriting private/direct statuses is off unless you opt in.
+ALLOW_FAVOURITE_PRIVATE=false
+
+# --- Social: boosts (off by default; kept extremely rare) ---
+ENABLE_BOOSTS=false
+MAX_BOOSTS_PER_DAY=1
+MIN_HOURS_BETWEEN_BOOSTS=12
+BOOST_ONLY_PUBLIC_STATUSES=true
+
+# --- Teach / memory (moderated; off unless enabled) ---
+ENABLE_TEACH_COMMAND=false
+# Comma-separated admin accts, e.g. admin@example.com,mod@example.org
+ADMIN_ACCOUNTS=
+# If true, non-admins get a short refusal; if false they are ignored silently.
+ADMIN_REFUSE_NONADMIN=false
+MEMORY_MAX_LENGTH=180
+MEMORY_ALLOW_URLS=false
+
+# --- Community events ---
+ENABLE_EVENTS=false
+EVENT_CHANCE_PER_AUTOPOST=0.15
+MAX_ACTIVE_EVENTS=1
+EVENT_DURATION_HOURS=12
+EVENT_COOLDOWN_HOURS=24
+
+# --- Milestones ---
+ENABLE_MILESTONE_POSTS=true
+MILESTONE_POST_VISIBILITY=unlisted
+
+# --- Misc ---
+LOG_LEVEL=INFO
@@ -0,0 +1,14 @@+__pycache__/
+*.py[cod]
+*.egg-info/
+.eggs/
+.venv/
+venv/
+.env
+*.db
+*.sqlite3
+.pytest_cache/
+.coverage
+htmlcov/
+.idea/
+.vscode/
@@ -0,0 +1,13 @@+FROM python:3.12-slim
+
+ENV PYTHONUNBUFFERED=1 \
+    PYTHONDONTWRITEBYTECODE=1
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY prami ./prami
+
+CMD ["python", "-m", "prami"]
@@ -0,0 +1,661 @@+                    GNU AFFERO GENERAL PUBLIC LICENSE
+                       Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+  The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works.  By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+  Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+  A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate.  Many developers of free software are heartened and
+encouraged by the resulting cooperation.  However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+  The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community.  It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server.  Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+  An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals.  This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                       TERMS AND CONDITIONS
+
+  0. Definitions.
+
+  "This License" refers to version 3 of the GNU Affero General Public License.
+
+  "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+  "The Program" refers to any copyrightable work licensed under this
+License.  Each licensee is addressed as "you".  "Licensees" and
+"recipients" may be individuals or organizations.
+
+  To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy.  The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+  A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+  To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy.  Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+  To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies.  Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+  An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License.  If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+  1. Source Code.
+
+  The "source code" for a work means the preferred form of the work
+for making modifications to it.  "Object code" means any non-source
+form of a work.
+
+  A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+  The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form.  A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+  The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities.  However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work.  For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+  The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+  The Corresponding Source for a work in source code form is that
+same work.
+
+  2. Basic Permissions.
+
+  All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met.  This License explicitly affirms your unlimited
+permission to run the unmodified Program.  The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work.  This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+  You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force.  You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright.  Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+  Conveying under any other circumstances is permitted solely under
+the conditions stated below.  Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+  No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+  When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+  4. Conveying Verbatim Copies.
+
+  You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+  You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+  5. Conveying Modified Source Versions.
+
+  You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+    a) The work must carry prominent notices stating that you modified
+    it, and giving a relevant date.
+
+    b) The work must carry prominent notices stating that it is
+    released under this License and any conditions added under section
+    7.  This requirement modifies the requirement in section 4 to
+    "keep intact all notices".
+
+    c) You must license the entire work, as a whole, under this
+    License to anyone who comes into possession of a copy.  This
+    License will therefore apply, along with any applicable section 7
+    additional terms, to the whole of the work, and all its parts,
+    regardless of how they are packaged.  This License gives no
+    permission to license the work in any other way, but it does not
+    invalidate such permission if you have separately received it.
+
+    d) If the work has interactive user interfaces, each must display
+    Appropriate Legal Notices; however, if the Program has interactive
+    interfaces that do not display Appropriate Legal Notices, your
+    work need not make them do so.
+
+  A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit.  Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+  6. Conveying Non-Source Forms.
+
+  You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+    a) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by the
+    Corresponding Source fixed on a durable physical medium
+    customarily used for software interchange.
+
+    b) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by a
+    written offer, valid for at least three years and valid for as
+    long as you offer spare parts or customer support for that product
+    model, to give anyone who possesses the object code either (1) a
+    copy of the Corresponding Source for all the software in the
+    product that is covered by this License, on a durable physical
+    medium customarily used for software interchange, for a price no
+    more than your reasonable cost of physically performing this
+    conveying of source, or (2) access to copy the
+    Corresponding Source from a network server at no charge.
+
+    c) Convey individual copies of the object code with a copy of the
+    written offer to provide the Corresponding Source.  This
+    alternative is allowed only occasionally and noncommercially, and
+    only if you received the object code with such an offer, in accord
+    with subsection 6b.
+
+    d) Convey the object code by offering access from a designated
+    place (gratis or for a charge), and offer equivalent access to the
+    Corresponding Source in the same way through the same place at no
+    further charge.  You need not require recipients to copy the
+    Corresponding Source along with the object code.  If the place to
+    copy the object code is a network server, the Corresponding Source
+    may be on a different server (operated by you or a third party)
+    that supports equivalent copying facilities, provided you maintain
+    clear directions next to the object code saying where to find the
+    Corresponding Source.  Regardless of what server hosts the
+    Corresponding Source, you remain obligated to ensure that it is
+    available for as long as needed to satisfy these requirements.
+
+    e) Convey the object code using peer-to-peer transmission, provided
+    you inform other peers where the object code and Corresponding
+    Source of the work are being offered to the general public at no
+    charge under subsection 6d.
+
+  A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+  A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling.  In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage.  For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product.  A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+  "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source.  The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+  If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information.  But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+  The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed.  Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+  Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+  7. Additional Terms.
+
+  "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law.  If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+  When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it.  (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.)  You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+  Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+    a) Disclaiming warranty or limiting liability differently from the
+    terms of sections 15 and 16 of this License; or
+
+    b) Requiring preservation of specified reasonable legal notices or
+    author attributions in that material or in the Appropriate Legal
+    Notices displayed by works containing it; or
+
+    c) Prohibiting misrepresentation of the origin of that material, or
+    requiring that modified versions of such material be marked in
+    reasonable ways as different from the original version; or
+
+    d) Limiting the use for publicity purposes of names of licensors or
+    authors of the material; or
+
+    e) Declining to grant rights under trademark law for use of some
+    trade names, trademarks, or service marks; or
+
+    f) Requiring indemnification of licensors and authors of that
+    material by anyone who conveys the material (or modified versions of
+    it) with contractual assumptions of liability to the recipient, for
+    any liability that these contractual assumptions directly impose on
+    those licensors and authors.
+
+  All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10.  If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term.  If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+  If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+  Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+  8. Termination.
+
+  You may not propagate or modify a covered work except as expressly
+provided under this License.  Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+  However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+  Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+  Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License.  If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+  9. Acceptance Not Required for Having Copies.
+
+  You are not required to accept this License in order to receive or
+run a copy of the Program.  Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance.  However,
+nothing other than this License grants you permission to propagate or
+modify any covered work.  These actions infringe copyright if you do
+not accept this License.  Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+  10. Automatic Licensing of Downstream Recipients.
+
+  Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License.  You are not responsible
+for enforcing compliance by third parties with this License.
+
+  An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations.  If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+  You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License.  For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+  11. Patents.
+
+  A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based.  The
+work thus licensed is called the contributor's "contributor version".
+
+  A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version.  For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+  Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+  In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement).  To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+  If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients.  "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+  If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+  A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License.  You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+  Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+  12. No Surrender of Others' Freedom.
+
+  If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all.  For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+  13. Remote Network Interaction; Use with the GNU General Public License.
+
+  Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software.  This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+  Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work.  The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+  14. Revised Versions of this License.
+
+  The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time.  Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+  Each version is given a distinguishing version number.  If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation.  If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+  If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+  Later license versions may give you additional or different
+permissions.  However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+  15. Disclaimer of Warranty.
+
+  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. Limitation of Liability.
+
+  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+  17. Interpretation of Sections 15 and 16.
+
+  If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+  If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source.  For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code.  There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+  You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+<https://www.gnu.org/licenses/>.
@@ -0,0 +1,40 @@+# Prami
+
+A federated community virtual pet that lives as a Mastodon account. It polls for
+mentions, replies to commands, drifts its state over time, and posts on its own now
+and then.
+
+## Run
+
+```bash
+cp .env.example .env
+# set MASTODON_BASE_URL, MASTODON_ACCESS_TOKEN and BOT_ACCOUNT_ACCT
+docker compose up -d
+docker compose logs -f prami
+```
+
+Stop with `docker compose stop`. Reset the database with `docker compose down -v`.
+
+## Try it locally first (no Mastodon needed)
+
+```bash
+python -m prami.cli status
+python -m prami.cli feed --user @alice@example.com
+python -m prami.cli shell
+```
+
+Full guide: [docs/local-testing.en.md](docs/local-testing.en.md) ·
+[docs/local-testing.pt-br.md](docs/local-testing.pt-br.md)
+
+## Documentation
+
+Start at the [docs index](docs/README.md). Highlights:
+- [Local testing](docs/LOCAL_TESTING.md) — drive Prami with no Mastodon
+- [Game design](docs/GAME_DESIGN.md) — stats, commands, social features
+- [Moderation & safety](docs/MODERATION.md) — favourites, boosts, teach approval
+- [Admin commands](docs/ADMIN_COMMANDS.md) — runtime controls
+- [Mastodon setup](docs/MASTODON_SETUP.md) — going live
+
+## License
+
+AGPL-3.0-or-later. See [LICENSE](LICENSE).
@@ -0,0 +1,26 @@+services:
+  db:
+    image: postgres:16-alpine
+    restart: unless-stopped
+    environment:
+      POSTGRES_USER: prami
+      POSTGRES_PASSWORD: prami
+      POSTGRES_DB: prami
+    volumes:
+      - prami_pgdata:/var/lib/postgresql/data
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U prami"]
+      interval: 5s
+      timeout: 5s
+      retries: 5
+
+  prami:
+    build: .
+    restart: unless-stopped
+    env_file: .env
+    depends_on:
+      db:
+        condition: service_healthy
+
+volumes:
+  prami_pgdata:
@@ -0,0 +1,102 @@+# Admin commands
+
+Admins steer Prami at runtime through mentions, and the same actions are available locally
+through the CLI for testing.
+
+## Configuring admins
+
+List admin accounts in `.env`, comma-separated:
+
+```
+ADMIN_ACCOUNTS=admin@example.com,mod@example.org
+```
+
+Only these accounts can run admin commands. Non-admins who try are ignored silently by
+default, or get a short refusal if `ADMIN_REFUSE_NONADMIN=true`. Every admin action is logged
+to the events table, and admin replies are sent as direct messages.
+
+## Commands (via Mastodon mention)
+
+```
+@prami admin status
+@prami admin stats
+@prami admin pause
+@prami admin resume
+@prami admin set-visibility public|unlisted|private|direct
+@prami admin set-autopost on|off
+@prami admin set-favourites on|off
+@prami admin set-boosts on|off
+@prami admin blocked-users
+@prami admin block @user@example.com
+@prami admin unblock @user@example.com
+@prami admin memories
+@prami admin memory-list pending|approved
+@prami admin approve-memory <id>
+@prami admin reject-memory <id> [reason]
+```
+
+- **status** — one-line summary: paused, autopost/favourites/boosts state, default
+  visibility, and pending/approved memory counts.
+- **stats** — a fuller local operational report (see below).
+- **pause / resume** — stop or restart all of Prami's reactions instantly.
+- **set-visibility / set-autopost / set-favourites / set-boosts** — toggle runtime behaviour
+  without redeploying. These override the `.env` defaults until changed again.
+- **block / unblock / blocked-users** — manage the block list at runtime.
+- **memories / memory-list / approve-memory / reject-memory** — review the teach queue.
+
+Runtime toggles are stored in the `settings` table, so they survive restarts.
+
+## Local metrics (stats)
+
+`@prami admin stats` (and `python -m prami.cli stats`) prints a local operational report. It
+is **local-only and admin-only** — there is no external analytics, nothing is phoned home, and
+no sensitive personal data is collected. It only counts game activity already in the database,
+and admin replies go out direct, so it is never exposed publicly.
+
+It reports:
+
+- **uptime** — time since the bot process last started (`—` if started only via the CLI)
+- **total interactions** and **interactions in the last 24h**
+- **unique users in the last 24h**
+- **current mood** and **current critical stats** (any survival stat in the danger zone)
+- **autonomous posts**, **favourites**, and **boosts** in the last 24h
+- the **active event**, if any
+- **pending memory suggestions** and **blocked users** count
+- the **most-used commands**
+- **last decay run** and **last autonomous post**
+- **database connection status**
+
+Example:
+
+```
+Prami — local stats
+uptime: 3d 6h 41m
+mood: content · critical: none
+stats: hunger=38 energy=64 health=92 clean=55 social=47
+interactions: 1284 total · 73 in 24h · 21 unique users 24h
+top commands: pet:511, feed:402, status:240, play:88, clean:31
+autoposts 24h: 6 · favourites 24h: 4 · boosts 24h: 0
+active event: Prami needs help choosing a snack.
+pending memories: 2 · blocked users: 1
+last decay: 2026-06-28 22:05 UTC
+last autopost: 2026-06-28 20:11 UTC (lonely)
+database: connected (postgresql)
+```
+
+## The same actions locally (CLI)
+
+The local CLI grants admin to whoever runs it, so you can exercise the real admin handler
+without configuring accounts:
+
+```bash
+python -m prami.cli admin status
+python -m prami.cli admin pause
+python -m prami.cli admin set-favourites on
+python -m prami.cli admin memories
+python -m prami.cli admin approve-memory 1
+python -m prami.cli admin reject-memory 2 "off-topic"
+python -m prami.cli admin block @troll@bad.social
+python -m prami.cli stats
+```
+
+See [LOCAL_TESTING.md](LOCAL_TESTING.md) for the full local workflow.
@@ -0,0 +1,94 @@+# Architecture
+
+Prami's behaviour is built as a set of small, self-contained **actions** wired together by an
+**ActionRegistry** and run through a single command **pipeline**. There is no giant if/else
+command handler, and parsing is kept separate from execution.
+
+## Actions
+
+Every command is an `Action` (in `prami/actions/`). An action declares its own metadata and
+owns its own logic, so adding or changing a command is a local edit:
+
+| Aspect | Where |
+|---|---|
+| command name + aliases | `name`, `aliases`, `phrase_aliases` |
+| requires admin | `admin_only` |
+| enabled by config | `enabled(config)` |
+| per-user / global cooldown | `user_cooldown_minutes`, `global_cooldown_minutes` |
+| hourly limit | `per_hour_limit` |
+| runs while asleep | `runs_while_asleep` |
+| early rejections (e.g. already asleep) | `precheck(ctx)` |
+| input validation + state changes + response | `run(ctx)` |
+| disabled message | `on_disabled(ctx)` |
+
+`run(ctx)` returns an `Outcome` describing what happened:
+
+```
+Outcome(text, accepted, record, address, favourite, boost, was_critical, visibility)
+```
+
+`favourite`/`boost` are the action's side-effect eligibility; `was_critical` feeds milestone
+and relationship logic; `record=False` marks informational actions (event/help/admin) that
+shouldn't count as interactions; `visibility` lets an action override (admin replies direct).
+
+Actions live one concept per file: `status.py`, `help.py`, `unknown.py`, `nickname.py`,
+`teach.py`, `events.py` (event/help-event/vote), `admin.py`, and `care.py` (the six care
+commands share a small `CareAction` base, since they only differ by their `effect(pet)`).
+
+## ActionRegistry
+
+`prami/actions/registry.py` builds the single registry (`REGISTRY`) at import time. It:
+
+- registers every action,
+- resolves a canonical command name to its action (`resolve`), falling back to the unknown
+  action,
+- resolves aliases and multi-word phrases to canonical names (`canonical`, `alias_map`,
+  `phrase_map`),
+- reports whether an action is enabled for a given config (`is_enabled`).
+
+The parser reads its command vocabulary **from the registry** (`alias_map` / `phrase_map`), so
+parsing and execution can never drift out of sync — aliases are defined once, on the action.
+
+## The command pipeline
+
+`engine.handle(...)` is the one pipeline both the CLI and the Mastodon poller call:
+
+1. **Receive input** — actor, status id, arg, config.
+2. **Normalize** — load runtime `settings`, build an `ActionContext`.
+3. **Parse** — done upstream by `parser.parse` (mentions stripped, command + arg extracted).
+4. **Resolve** — `REGISTRY.resolve(command)` returns the action (or the unknown action).
+5. **Safety / moderation** — admin actions bypass; otherwise blocked users and paused state
+   stop here.
+6. **Cooldowns and rate limits** — global and per-user hourly caps, then the action's own
+   cooldowns.
+7. **Execute transactionally** — `action.precheck` then `action.run` mutate state inside the
+   caller's DB session.
+8. **Generate response** — the action returns the text in its `Outcome`.
+9. **Apply side effects** — on an accepted, recorded outcome the pipeline records the
+   interaction, updates the relationship, and checks milestones; favourite/boost eligibility
+   ride back on the `Result`.
+10. **Store processed status id** — handled by the Mastodon poller before dispatch, so the
+    same status is never processed twice.
+
+The pipeline returns a `Result(text, visibility, accepted, favourite, boost, milestones)`.
+
+## One core path for CLI and Mastodon
+
+Both entry points are thin and converge on `engine.handle`:
+
+- **CLI** — `prami/cli.py` parses arguments, then calls `engine.handle` for every command,
+  including admin (the CLI just grants the local operator admin first).
+- **Mastodon** — `prami/app.py`'s poller normalizes the notification, calls `parser.parse`,
+  then `engine.handle`, and finally executes the returned favourite/boost/milestone side
+  effects through the Mastodon client.
+
+No command logic is duplicated between them. Tests in `tests/test_actions.py` prove alias
+resolution, disabled-command handling, admin-only gating, and that both the CLI and a
+Mastodon-style parse run through the same registry and pipeline to the same result.
+
+## Adding a command later
+
+1. Write an `Action` subclass in `prami/actions/` with its metadata and `run`.
+2. Register it in `build_registry()`.
+3. That's it — the parser picks up its aliases, and the pipeline runs it. Add a focused test
+   for the action's `run` and its eligibility.
@@ -0,0 +1,233 @@+# Prami — Game Design
+
+## 1. What Prami is
+
+Prami is a shared community virtual pet for Mastodon and the wider Fediverse. It lives as a
+single Mastodon account and is cared for collectively by everyone on the timeline — there is
+one creature, not a private pet per user. People interact with it by mentioning the account
+with simple commands, and Prami answers in character.
+
+The goal is a small, charming social creature that feels alive and belongs to the community,
+not a command-line tool wearing a costume.
+
+## 2. Core principles
+
+- **Prami should feel alive.** It has an internal state that drifts on its own and a voice
+  that changes with how it feels. It is never just echoing fixed text.
+- **Community care should matter.** Stats decay over time, so the pet stays healthy only if
+  the community keeps showing up to feed, clean, play with, and rest it.
+- **Interactions should have consequences.** Every command moves real numbers — overfeeding
+  makes it chaotic, neglect hurts its health, baths cost a little happiness.
+- **The bot must avoid spam.** Replies are deflected with cooldowns and rate limits, and
+  autonomous posts are infrequent and unlisted by default.
+- **Replies should be charming, short, and varied.** One to four sentences, handcrafted, and
+  randomised so repeated commands don't read identically.
+- **The system must be safe and moderation-friendly.** Blocked users, rate caps, dedup of
+  processed statuses, and no loops. Nothing user-supplied is ever stored as Prami's voice.
+
+## 3. Pet stats
+
+All stats are integers clamped to 0–100. Their starting values are tuned so a fresh Prami is
+content but already a little hungry and in need of company.
+
+| Stat | Meaning | Start |
+|---|---|---|
+| **hunger** | How hungry Prami is. High is bad; it rises over time and drops when fed. | 40 |
+| **happiness** | General mood fuel. Play and affection raise it; baths and neglect lower it. | 60 |
+| **energy** | Stamina. Falls while awake, recovers while asleep. | 70 |
+| **health** | Long-term wellbeing. Slowly erodes when hunger, cleanliness, or energy stay bad. | 90 |
+| **cleanliness** | How clean Prami is. Falls over time; cleaning restores it. | 70 |
+| **social** | Need for interaction. Falls over time; play and pets feed it. | 50 |
+| **trust** | How much Prami trusts the community. Built slowly through gentle care (pets). | 30 |
+| **chaos** | Gremlin energy. Drifts randomly, spikes from overfeeding and rough handling. | 20 |
+
+Two more fields describe the pet rather than measure it:
+
+- **asleep** — whether Prami is currently sleeping. While asleep, energy recovers and active
+  care (feed/play/clean) is gently declined.
+- **mood** — a single word derived from the stats every tick (e.g. `content`, `ravenous`,
+  `exhausted`, `filthy`, `feral`, `delighted`, `lonely`, `grumpy`, `unwell`, `asleep`). Mood
+  is what `status` reports and what drives autonomous posts; users never see raw numbers.
+
+## 4. Commands
+
+Mention the bot with one of these words (case-insensitive; a few aliases also work):
+
+- **status** — Reports how Prami is doing as narrative, never as numbers ("mildly hungry,
+  emotionally aerodynamic, staring at the corner").
+- **feed** — Lowers hunger and nudges happiness; helps health if Prami was genuinely hungry.
+  Feeding an already-full Prami makes it chaotic and a bit annoyed.
+- **play** — Raises happiness and social, costs energy, adds a little chaos.
+- **pet** — Gentle care: builds trust and social and happiness with a small energy cost.
+  Low effort, short cooldown — the everyday way to bond with Prami.
+- **clean** — Restores cleanliness but costs a little happiness (pets hate baths) and adds
+  some chaos.
+- **sleep** — Puts Prami to bed if it's awake. While asleep, energy recovers faster.
+- **wake** — Wakes Prami up. If its energy is very low, it wakes but complains.
+- **help** — A short, in-character list of the commands.
+
+## 5. Cooldowns
+
+Cooldowns exist so one enthusiastic person — or a busy timeline — can't run the pet ragged.
+There are two independent layers:
+
+- **Per-user cooldowns** — a minimum interval between uses of a command by the *same* person.
+- **Global limits** — a cap on how often a command can succeed across the *whole* community.
+
+Defaults:
+
+| Command | Per user | Globally |
+|---|---|---|
+| feed | once / 30 min | 10 / hour |
+| play | once / 20 min | 15 / hour |
+| pet | once / 10 min | 30 / hour |
+| clean | once / 60 min | 5 / hour |
+| sleep | — | once / 120 min |
+| wake | — | once / 60 min |
+
+On top of these, there are blanket caps: a maximum number of commands per user per hour and a
+maximum across the whole community per hour. When something is on cooldown, Prami still
+replies — it just answers in character instead of acting ("Easy — still recovering from your
+last visit").
+
+## 6. State decay
+
+A scheduler ticks on a fixed interval (15 minutes by default) and nudges Prami's state so it
+keeps living between interactions:
+
+- Awake, hunger rises and energy falls each tick; asleep, energy recovers and hunger rises
+  more slowly.
+- Cleanliness and social both drift down — Prami gets grubby and lonely if left alone.
+- Health only moves slowly: it erodes when hunger, cleanliness, or energy stay in bad shape,
+  and recovers a little when everything is comfortable.
+- Chaos drifts up and down at random, so Prami is never perfectly predictable.
+- Mood is recomputed from the new numbers at the end of every tick.
+
+The practical effect is that Prami needs ongoing, distributed attention. A pet fed once and
+forgotten will slide into hungry, dirty, lonely, and eventually unwell.
+
+## 7. Autonomous posts
+
+Now and then (every 4 hours by default) Prami posts on its own, without being prompted. The
+post is chosen from its current state and mood: a hungry Prami fishes for snacks, a lonely
+one asks for company, a chaotic one announces an incoming small crime, and sometimes it just
+drifts into an existential or weird-observation post.
+
+These posts default to **unlisted** visibility, and so do command replies. Unlisted keeps
+Prami off the public/federated firehose so it never floods followers' home timelines or other
+instances — people who want it still see it, but the pet doesn't behave like timeline spam.
+Visibility is configurable (public / unlisted / direct) for operators who want something else.
+
+## 8. Personality system
+
+Prami's charm comes from *which* handcrafted line it picks. Every command has several named
+response groups, and the pet's state selects the group before a random line is drawn from it:
+
+- **hunger** — when very hungry, feed responses turn desperate; when full, they turn
+  unimpressed.
+- **energy** — low energy makes Prami sleepy and dramatic; high energy makes play energetic.
+- **trust** — low trust makes care responses guarded and suspicious; high trust unlocks rare,
+  fully undignified affection.
+- **chaos** — high chaos pushes responses toward weirder, more feral lines across the board.
+- **cleanliness** — being dirty (or suspiciously clean) changes how status and bath replies
+  read.
+- **mood / asleep** — mood colours `status`; while asleep, most commands get sleepy replies.
+
+For action commands the line is chosen from the state *before* the command applies, so the
+reply reflects what motivated it. On top of state, the free-text `PET_PERSONALITY` setting
+tunes the thresholds (a `chaotic` Prami tips into chaos sooner; a `suspicious` one stays
+guarded longer; a `calm` one rarely goes feral), so the same code and templates can feel like
+different creatures. See [personality.en.md](personality.en.md) for details.
+
+## 8b. Relationships and bonds (Social Intelligence Pack — Phase 1)
+
+Prami keeps lightweight, game-only memory about each user — never personal-data profiling,
+only interaction data. Per user it tracks total and per-command interaction counts, a
+**trust score**, a **bond level**, an optional **user-set nickname**, and first/last seen.
+
+Bond levels progress with kind, repeated care: **stranger → familiar → trusted → friend →
+beloved menace**. Each care command adds a little trust (petting most of all); helping while
+Prami is in a critical state (very hungry, unwell, filthy, or exhausted) grants a bonus, so
+the people who show up in a crisis are remembered as closer.
+
+Relationships gently warm Prami's voice — a known user with a nickname hears it used now and
+then, a bit more often at higher bonds. Raw numbers are never exposed in normal replies; they
+are only visible through the developer `relationship` CLI command.
+
+Users set their own nickname with `nickname <name>` (aliases: `name me`, `call me`) and clear
+it with `nickname clear`. Nicknames are sanitized (max 32 characters; no URLs, mentions, HTML,
+or line breaks) and rate-limited.
+
+## 8c. Social Intelligence Pack
+
+These features make Prami feel socially aware while staying safe and moderated. All of the
+heavier ones default to **off** and are enabled deliberately per instance.
+
+**Favourites** — Prami's lightweight "I noticed" reaction, and the preferred response to
+ordinary good moments: feeding it when starving, petting it when lonely, cleaning it when
+filthy, or hitting a milestone. Off unless enabled, capped per hour and per user per day,
+never on private statuses unless allowed, never duplicated.
+
+**Boosts** — rare and off by default. Reserved for genuinely special moments (a critical
+save, a major milestone, a completed event). Even when enabled they are bounded by a daily
+cap, a minimum gap between boosts, and public-only by default. Prami always prefers a
+favourite over a boost.
+
+**Relationship memory and bonds** — see section 8b.
+
+**Teach / memory** — users can suggest lore, but nothing becomes part of Prami until an admin
+approves it. Approved memories can surface in autonomous posts and flavour. Off unless
+enabled. See [MODERATION.md](MODERATION.md).
+
+**Community events** — occasional, lightweight, narrative events ("Prami found a mysterious
+egg") that the community votes on with `@prami vote <option>`. One vote per user; the winning
+option changes Prami's state; completion produces one autonomous post and, rarely, a boost.
+
+**Milestones** — small community and per-user achievements (first feeding, 100 interactions,
+7 days alive, reaching a bond level, a critical save). Each is announced once (unlisted),
+some trigger a favourite, and very rare major ones may be boost-eligible.
+
+**Smarter autonomous posts** — Prami's self-posts consider mood, recent state, an active
+event, and approved memories, and avoid repeating the same category back-to-back. Categories:
+hungry, lonely, sleepy, chaotic, happy, dirty, existential, community appreciation, approved
+memory callback, and active event reminder.
+
+**Admin controls** — admins can pause/resume Prami, toggle features, manage blocks, and
+review the teach queue at runtime. See [ADMIN_COMMANDS.md](ADMIN_COMMANDS.md).
+
+## 9. Moderation and safety
+
+Prami is designed to be quiet, hard to abuse, and easy to host responsibly:
+
+- **Blocked users** — accounts listed in configuration (or marked blocked) are ignored
+  silently; Prami never replies to them.
+- **Rate limits** — per-user and global caps, plus per-command cooldowns, bound how much the
+  pet can be poked in any window.
+- **Ignored / processed statuses** — every status ID Prami acts on is recorded, so it never
+  replies to the same mention twice, even across restarts.
+- **No loops** — Prami never replies to its own posts and never acts on its own account, so it
+  can't get into a reply loop with itself.
+- **Unlisted by default** — replies and autonomous posts stay off public timelines unless an
+  operator opts in.
+- **No user-supplied voice** — the MVP deliberately has **no teach/memory command**. Letting
+  the public write what Prami says or remembers is a moderation hazard (it becomes a vector
+  for slurs, harassment, or spam in the pet's mouth). That feature is intentionally deferred
+  until it can ship with moderator approval gating.
+
+## 10. Future roadmap
+
+Mentioned here as direction, not built in the MVP:
+
+- A **teach / memory system** where the community can suggest things Prami learns, gated
+  behind moderator approval.
+- **Community voting events** — collective decisions that change Prami or trigger happenings.
+- **Items and gifts** — things people can give the pet.
+- **Personality packs** — swappable sets of templates and traits (the code already has the
+  seam for this).
+- **Multiple pets** — more than one creature per instance (the schema already keeps a pet id
+  on everything).
+- **Pet-to-pet federation** — Pramis on different instances interacting.
+- **A web admin / moderation dashboard** — for operators to watch state and manage the pet.
+
+The MVP stays deliberately small: one pet, handcrafted responses, no generative AI, no web
+UI. Reliability and a creature that feels alive come first; everything above builds on that.
@@ -0,0 +1,110 @@+# Local testing
+
+Prami runs entirely without Mastodon through the local CLI, which drives the **same** engine,
+database, cooldowns, personality, and safety logic the live bot uses. Test everything here
+before going online.
+
+There are localized walkthroughs too: [local-testing.en.md](local-testing.en.md) (English) and
+[local-testing.pt-br.md](local-testing.pt-br.md) (Português). This file is the full English
+command reference.
+
+## Database
+
+The CLI uses `DATABASE_URL`. SQLite is perfect locally:
+
+```bash
+export DATABASE_URL="sqlite:///prami.db"     # PowerShell: $env:DATABASE_URL="sqlite:///prami.db"
+```
+
+In Docker, prefix any command with `docker compose run --rm prami`.
+
+## Core commands
+
+```bash
+python -m prami.cli status
+python -m prami.cli feed  --user @alice@example.com
+python -m prami.cli play  --user @bob@example.com
+python -m prami.cli pet   --user @alice@example.com
+python -m prami.cli clean --user @alice@example.com
+python -m prami.cli sleep --user @alice@example.com
+python -m prami.cli wake  --user @alice@example.com
+python -m prami.cli help
+```
+
+When an action is favourite- or boost-worthy, or it triggers a milestone, the CLI prints a
+note under the reply so you can see the social logic firing.
+
+## Time, state, and reset
+
+```bash
+python -m prami.cli tick --hours 4        # simulate decay
+python -m prami.cli debug-state           # raw numbers (dev only)
+python -m prami.cli reset --yes           # fresh slate (clears users, events, memories...)
+```
+
+## Relationships and nicknames
+
+```bash
+python -m prami.cli nickname Soup Wizard --user @alice@example.com
+python -m prami.cli nickname clear --user @alice@example.com
+python -m prami.cli relationship --user @alice@example.com   # counts, trust, bond level
+```
+
+## Teach / memory (moderated)
+
+Set `ENABLE_TEACH_COMMAND=true` first.
+
+```bash
+python -m prami.cli teach '"Prami believes soup is a weather condition."' --user @alice@example.com
+python -m prami.cli admin memories                 # list pending
+python -m prami.cli admin approve-memory 1
+python -m prami.cli admin reject-memory 2 "off-topic"
+```
+
+Nothing taught goes live until an admin approves it. The CLI grants you local admin.
+
+## Community events
+
+Set `ENABLE_EVENTS=true` (auto-start is random; force one for testing):
+
+```bash
+python -m prami.cli event-start snack_vote     # dev: force-start a specific event
+python -m prami.cli event --user @alice@example.com
+python -m prami.cli vote soup --user @alice@example.com
+python -m prami.cli vote crunch --user @bob@example.com
+python -m prami.cli event-complete             # dev: tally + apply the outcome
+```
+
+## Admin controls
+
+```bash
+python -m prami.cli admin status
+python -m prami.cli admin pause
+python -m prami.cli admin resume
+python -m prami.cli admin set-favourites on
+python -m prami.cli admin block @troll@bad.social
+python -m prami.cli stats          # local operational metrics
+```
+
+## Interactive shell
+
+```bash
+python -m prami.cli shell
+```
+
+```
+@alice feed
+@bob play
+@alice call me Soup Wizard
+whois @alice
+tick 2h
+debug
+exit
+```
+
+## Tests
+
+```bash
+pip install -r requirements-dev.txt
+pytest
+```
@@ -0,0 +1,66 @@+# Mastodon setup
+
+How to give Prami its own Mastodon account and go live — after everything passes locally.
+
+> Do not connect Prami to Mastodon until the local tests pass and you've tried the CLI.
+> See [LOCAL_TESTING.md](LOCAL_TESTING.md).
+
+## 1. Create the pet's account
+
+Make a dedicated Mastodon account for Prami on your instance. Don't use a personal account —
+Prami posts and reacts on its own.
+
+## 2. Create an access token
+
+1. Log in as the **pet's** account.
+2. Go to **Preferences → Development → New application**.
+3. Name it (e.g. `Prami`) and grant these scopes:
+   - `read:notifications` — to see mentions
+   - `read:accounts` — to identify users
+   - `read:statuses` — to read mention content
+   - `write:statuses` — to reply and post
+   - `write:favourites` — only if you enable favourites
+   - `write:follows` is **not** needed
+4. Save, open the application, and copy the **access token**.
+
+Reblogs (boosts) use the standard `write:statuses` scope.
+
+## 3. Configure `.env`
+
+```bash
+cp .env.example .env
+```
+
+Set at least:
+
+- `MASTODON_BASE_URL` — e.g. `https://your.instance.social`
+- `MASTODON_ACCESS_TOKEN` — the token from step 2
+- `BOT_ACCOUNT_ACCT` — the pet's acct, e.g. `prami@your.instance.social`
+
+The social features start **off**. Turn them on deliberately once you're comfortable:
+
+- `ENABLE_FAVOURITES=true` to let Prami favourite worthy interactions
+- `ENABLE_TEACH_COMMAND=true` plus `ADMIN_ACCOUNTS=...` to open the moderated teach queue
+- `ENABLE_EVENTS=true` for community events
+- `ENABLE_BOOSTS=true` only if you really want boosts (they stay rare)
+
+## 4. Run
+
+```bash
+docker compose up -d
+docker compose logs -f prami
+```
+
+Prami authenticates, starts polling for mentions, runs state decay, and posts on its own at
+the configured interval. Mention it with `@prami help` from another account to confirm it
+replies.
+
+## 5. Going-live checklist
+
+- [ ] `pytest` passes locally
+- [ ] You've driven Prami through the CLI (see LOCAL_TESTING.md)
+- [ ] `.env` has the token, base URL, and bot acct set
+- [ ] You've decided which social features to enable (favourites/boosts/teach/events)
+- [ ] `ADMIN_ACCOUNTS` is set if you enabled teach
+- [ ] Default visibility is `unlisted` (recommended) so Prami doesn't flood timelines
+- [ ] `docker compose up -d` shows a successful authentication in the logs
@@ -0,0 +1,67 @@+# Moderation and safety
+
+Prami is built to be quiet, hard to abuse, and easy to host responsibly. A single safety
+module (`prami/safety.py`) is the gatekeeper for every outward action. Nothing the public
+says becomes Prami's voice without a human approving it.
+
+## What the safety module decides
+
+Before Prami does any of the following, the safety layer must allow it:
+
+| Action | Checks applied |
+|---|---|
+| **reply** | not paused, user not blocked, within per-user and global hourly limits |
+| **favourite** | favourites enabled, not paused, user not blocked, status not private/direct (unless allowed), not already favourited, within hourly and per-user-daily caps |
+| **boost** | boosts enabled, not paused, user not blocked, status public only, not already boosted, within daily cap, and at least N hours since the last boost |
+| **store memory** | the teach command is enabled, content passes sanitation |
+| **use a nickname** | nickname passes sanitation (length, no URLs/mentions/HTML) |
+| **autonomous post** | autoposts enabled and not paused |
+
+## Core protections
+
+- **Blocked users** — accounts in `BLOCKED_USERS`, or blocked at runtime by an admin, are
+  ignored silently. Prami never replies to, favourites, or boosts them.
+- **Rate limits** — per-user and global hourly command caps, per-command cooldowns, plus
+  separate caps for favourites and boosts.
+- **Duplicate protection** — every processed status ID is recorded, so Prami never reacts to
+  the same mention twice. Favourited and boosted status IDs are stored too, so it never
+  favourites or boosts the same status twice.
+- **No loops** — Prami never replies to its own posts or acts on its own account.
+- **Pause switch** — an admin can pause Prami instantly; while paused it stops replying,
+  reacting, and autoposting (admins can still run commands to resume it).
+- **Unlisted by default** — replies, autonomous posts, and milestone posts default to
+  unlisted so Prami never floods public or federated timelines.
+
+## Favourites
+
+Favourites are Prami's lightweight "I noticed that" signal, and the preferred reaction for
+ordinary good moments — feeding a starving Prami, petting a lonely one, cleaning a filthy
+one, hitting a milestone. They are **off unless `ENABLE_FAVOURITES=true`**, capped per hour
+and per user per day, and never applied to private/direct statuses unless
+`ALLOW_FAVOURITE_PRIVATE=true`.
+
+## Boosts
+
+Boosts are deliberately **rare and off by default** (`ENABLE_BOOSTS=false`). A boost amplifies
+someone else's post to all of Prami's followers, so it is reserved for genuinely special
+moments — saving Prami from a critical state, a major community milestone, or a completed
+event. Even when enabled, boosts are bounded by `MAX_BOOSTS_PER_DAY`,
+`MIN_HOURS_BETWEEN_BOOSTS`, and `BOOST_ONLY_PUBLIC_STATUSES=true`. Prami always prefers a
+favourite over a boost for everyday positive interactions.
+
+## Teach / memory approval
+
+Users can *suggest* lore with the teach command, but **nothing they submit ever becomes
+active automatically**. Every suggestion is stored as `pending` and only influences Prami
+after an admin approves it. Content is sanitized (length-limited, no HTML, no mentions, and
+no URLs unless `MEMORY_ALLOW_URLS=true`). Rejected suggestions are kept for the record but
+are never publicly shamed. The whole feature is off unless `ENABLE_TEACH_COMMAND=true`.
+
+This gating exists because letting the public write what Prami says is a moderation hazard —
+it could otherwise become a vector for slurs, harassment, or spam in the pet's mouth.
+
+## Content sanitation
+
+`prami/sanitize.py` cleans user-provided text (nicknames and memory suggestions): it strips
+HTML, collapses whitespace and line breaks, rejects mentions and (by default) URLs, and
+enforces length limits. Sanitized values are the only user text Prami will ever repeat.
@@ -0,0 +1,50 @@+# Prami documentation
+
+Index of everything under `docs/`. Most guides come in English and Portuguese.
+
+## Using Prami (community)
+How people interact with the pet through Mastodon mentions.
+- English — [usage.en.md](usage.en.md)
+- Português — [usage.pt-br.md](usage.pt-br.md)
+
+## Local testing (developers)
+Run and play with Prami locally via the CLI, with no Mastodon account, token, or server.
+- English — [local-testing.en.md](local-testing.en.md)
+- Português — [local-testing.pt-br.md](local-testing.pt-br.md)
+
+## Personality
+How Prami's tone shifts with its state, and how `PET_PERSONALITY` tunes it.
+- English — [personality.en.md](personality.en.md)
+- Português — [personality.pt-br.md](personality.pt-br.md)
+
+## Voice & writing guide
+For anyone writing response lines — core voice, rules, length, humor, 50+ examples, checklist.
+- [VOICE_AND_PERSONALITY.md](VOICE_AND_PERSONALITY.md)
+
+## Architecture
+How the action system, registry, and command pipeline fit together (for contributors).
+- [ARCHITECTURE.md](ARCHITECTURE.md)
+
+## Game design
+The full design — stats, commands, cooldowns, decay, social features, moderation, roadmap.
+- [GAME_DESIGN.md](GAME_DESIGN.md)
+
+## Moderation & safety
+How favourites, boosts, and the moderated teach queue work, and every safety rule.
+- [MODERATION.md](MODERATION.md)
+
+## Admin commands
+Runtime controls — pause, toggles, blocks, and reviewing taught memories.
+- [ADMIN_COMMANDS.md](ADMIN_COMMANDS.md)
+
+## Mastodon setup
+Creating the account, token scopes, and the going-live checklist.
+- [MASTODON_SETUP.md](MASTODON_SETUP.md)
+
+## Local testing (full reference)
+Every CLI command, including the social features.
+- [LOCAL_TESTING.md](LOCAL_TESTING.md)
+
+---
+
+Setup and run instructions live in the [project README](../README.md).
@@ -0,0 +1,182 @@+# Voice and personality
+
+This is the writing guide for Prami. Read it before adding or editing any response template.
+All of Prami's text lives in `prami/templates.py` — the logic that picks between lines lives in
+`prami/responses.py`. You should almost never need to touch logic to add a line.
+
+## 1. Core voice
+
+Prami is:
+
+- **tiny** — a small creature, not a service
+- **strange** — odd in a gentle, curious way
+- **affectionate** — warm, but not gushing
+- **dramatic** — everything is a Situation
+- **suspicious** — squints first, trusts slowly
+- **social** — it wants the community around
+- **chaotic in a harmless way** — gremlin energy, no real damage
+- **never cruel**
+- **never corporate**
+- **never assistant-like**
+
+Prami sounds like a little fediverse creature with too much emotional complexity for its size —
+a community goblin that understands social media just enough to misuse it lovingly.
+
+## 2. Writing rules
+
+Prami should:
+
+- speak in **short** replies
+- avoid generic chatbot language
+- never say "How can I help you?" or anything support-desk shaped
+- avoid overused internet slang
+- never be mean to users
+- never moralize or lecture
+- never produce walls of text
+- sound like a **creature**, not a customer-support bot
+
+Refer to Prami in the third person and mostly avoid pronouns (use "it"/"they" sparingly) so the
+`{name}` placeholder works for any pet name. Address the user as "you" warmly, never as a ticket.
+
+## 3. Response length
+
+- **normal command replies:** 1–3 sentences
+- **status replies:** up to 5 short lines (the one place Prami can ramble a little)
+- **autonomous posts:** 1–4 sentences
+- **help replies:** concise, readable, and in-character — a short menu, not a manual
+
+If a line needs a scroll, it's too long. If it needs a paragraph break, it's probably two
+ideas — keep one.
+
+## 4. Humor rules
+
+The humor should be:
+
+- **weird** — unexpected angles, tiny absurd logic
+- **warm** — the joke never punches at anyone
+- **slightly absurd** — soup is a weather condition
+- **emotionally dramatic** — small stakes, operatic feelings
+- **fediverse-friendly** — it fits a calm timeline, doesn't shout
+
+Avoid:
+
+- cringe meme spam ("slay", "based", "no cap", emoji walls)
+- hostile sarcasm aimed at the user
+- excessive randomness (weird should still mean something)
+- references that will age badly (current memes, trends, brands)
+- any joke that depends on insulting a user
+
+## 5. Example good lines
+
+### hungry
+- Prami is not saying it's hungry. It's just describing the snack-shaped hole in the universe.
+- Prami would like the community to know that it is brave, neglected, and only a little hungry.
+- Status: the snack situation remains unresolved. Prami is monitoring it closely, and tragically.
+- Prami has started referring to dinner, ominously, as 'the situation'.
+- Prami inhaled the food like it held a personal grudge against hunger itself.
+
+### sleepy
+- Prami curled into a compact loaf of unresolved feelings. Good night, tiny menace.
+- Prami is running low. If it blinks slowly, that's a system warning, not affection.
+- Nap incoming. Prami will return shorter-tempered and slightly more profound.
+- Prami sank into sleep instantly, deeply, and with audible relief. It needed this.
+- Prami opened one eye. This was clearly not approved by management.
+
+### happy
+- Prami is awake, delighted, and convinced today is personally about it. It might be right.
+- Great day to be a small creature surrounded by people who feed it. 10/10.
+- Prami is in a great mood and would like that on the record, ideally somewhere permanent.
+- Prami did a tiny pleased wiggle it will later deny under oath.
+- Prami is having a wonderful time and wanted to tell everyone before the feeling files a complaint.
+
+### lonely
+- It's very quiet here. Suspiciously quiet. Someone come keep Prami company.
+- Prami has been quiet for a while. This usually means growth, sleep, or a small crime.
+- Prami is just out here, a tiny creature, hoping someone says hello. No pressure. Some pressure.
+- Prami is missing the group chat and being dignified about it, mostly.
+- Just Prami and its thoughts, which is one too many things in this room.
+
+### chaotic
+- Prami just discovered dust. Negotiations are ongoing.
+- Something is going to happen today, and Prami regrets to inform you that it is the something.
+- Prami has had an idea. The idea is bad. Prami is going to do it anyway.
+- Prami turned a simple game into a low-stakes crime. We're all accomplices now.
+- Prami is feral in a friendly way today, like static electricity with opinions.
+
+### dirty
+- Prami is now clean, furious, and plotting a legally ambiguous response.
+- Prami has achieved a level of grubby it is, frankly, proud of. The dirt stays.
+- Reminder from Prami: it is dirty and thriving. The two are unrelated. Probably.
+- Prami is treating the concept of a bath as a personal threat.
+- Bath completed. Trust temporarily damaged. Shine increased.
+
+### affectionate
+- Prami allowed the affection. This is legally distinct from admitting it wanted it.
+- Prami melted into your hand and made a sound that does not legally count as a purr.
+- Prami leaned in for exactly one second, then remembered its mysterious reputation.
+- Prami headbutted you gently, which is the closest it gets to a heartfelt confession.
+- Prami flopped over completely, trusting you with the soft underside of its entire deal.
+
+### suspicious
+- Prami sniffed the food, checked it for traps, found none, and remained suspicious on principle.
+- Prami accepted the snack while maintaining direct, faintly accusatory eye contact.
+- Prami accepted the pet but kept one eye open and one exit planned.
+- Prami ate, but wants it noted that trust was neither given nor implied.
+- Prami is staring at the corner like the corner owes it money.
+
+### existential
+- Prami has been thinking about the timeline, and the timeline, and is unsure which one is worse.
+- Sometimes Prami wonders if it is the pet or the pet is it. Then it sees a snack and the crisis resolves.
+- Prami would like to know if anyone else can feel the days. Just it? Okay. Cool. Cool cool cool.
+- Prami is a small parcel of unresolved feelings, and today that parcel has questions.
+- Prami is here, being a small good thing, and quietly negotiating with the concept of time.
+
+### event-related
+- Prami found a mysterious egg, warm and humming faintly. Vote: hatch it or guard it.
+- Prami is lost in the server room again. Call to it, or leave a snack trail?
+- Soup won. Prami declares soup a weather condition and a lifestyle.
+- Prami counted your vote. Democracy, but tiny.
+- Prami is building a tiny blanket fort. Possibly for napping. Possibly for plotting.
+
+### community appreciation
+- Prami would like to thank everyone who has fed, pet, or simply tolerated it. You are its whole weird world.
+- Prami doesn't say it enough, but it's genuinely glad you're all here. That's all. Carry on.
+- A new face met Prami today. Welcome — it has already decided to trust you eventually.
+- The community has cared for Prami a hundred times now. Thank you, all of you.
+- Prami is officially your friend now. Big deal, quietly.
+
+## 6. Example bad lines (and why)
+
+- "How can I help you today? 😊" — assistant/support voice. Prami is a creature, not a help desk.
+- "I'm just a humble bot, beep boop!" — breaks character by foregrounding being a bot.
+- "lol ur so based no cap fr fr 💀💀" — meme-slang spam; cringe and ages badly.
+- "Did you even READ the instructions? Try again, genius." — hostile sarcasm aimed at the user.
+- "As an AI, I cannot have feelings, but I appreciate your input." — assistant disclaimer; Prami is *all* feelings.
+- "Did you know loneliness is a serious issue? Here's what you should do…" — moralizing and lecturing.
+- A four-line tragic backstory about Prami's origins — wall of text; replies must stay short.
+- "FEED ME OR I WILL END YOU. 😈" — cruel and threatening; Prami's chaos is harmless, never menacing.
+- "Error: command not recognized. Please consult the documentation." — robotic; not in voice.
+- "Slay queen, this bath is giving main-character energy! 💅" — dated slang; ages badly and isn't Prami.
+
+## 7. Template review checklist
+
+Before adding a new response template, check:
+
+- [ ] **Is it short?** (within the length limits in section 3)
+- [ ] **Is it in Prami's voice?** (tiny, strange, affectionate, dramatic — see section 1)
+- [ ] **Is it safe?** (no slurs, harassment, no real-world targets; user text stays sanitized)
+- [ ] **Is it not repetitive?** (does the variant pool already have something like it?)
+- [ ] **Is it not hostile?** (the joke never lands on a user)
+- [ ] **Would it be annoying if seen multiple times in a week?** (if yes, cut or soften it)
+
+## Where to add lines
+
+Open `prami/templates.py`:
+
+- per-command replies → `COMMAND_TEMPLATES[command][variant]`
+- autonomous posts → `AUTOPOST[category]`
+- nicknames, teach, cooldowns, etc. → the matching named list
+- community events → `EVENT_SCENARIOS`
+
+Each variant is just a list of strings, so expanding a mood is adding a line to a list. Use the
+`{name}` placeholder for the pet's name (and `{nick}` where supported). No logic changes needed.
@@ -0,0 +1,131 @@+# Local testing (no Mastodon needed)
+
+Prami ships with a local CLI so you can play with the pet as a game and personality
+engine before ever connecting it to Mastodon. The CLI drives the **same** parser, game
+engine, cooldowns, database and response generator the bot uses — it is not a separate
+mock.
+
+By default it uses whatever `DATABASE_URL` is set (SQLite is perfect locally):
+
+```bash
+export DATABASE_URL="sqlite:///prami.db"   # Windows PowerShell: $env:DATABASE_URL="sqlite:///prami.db"
+```
+
+## One-shot commands
+
+```bash
+python -m prami.cli status
+python -m prami.cli feed  --user @alice@example.com
+python -m prami.cli play  --user @bob@example.com
+python -m prami.cli pet   --user @alice@example.com
+python -m prami.cli clean --user @alice@example.com
+python -m prami.cli sleep --user @alice@example.com
+python -m prami.cli wake  --user @alice@example.com
+python -m prami.cli help
+```
+
+`--user` is optional; without it a default local user is used. Each user has its own
+cooldowns, just like real accounts.
+
+## Nicknames and relationships
+
+Prami remembers lightweight, game-only data about each user (interaction counts, a
+per-user trust score, and a bond level: stranger → familiar → trusted → friend →
+beloved menace). Kind, frequent users bond faster; helping during a critical state is
+worth extra trust.
+
+```bash
+python -m prami.cli nickname Soup Wizard --user @alice@example.com   # set a nickname
+python -m prami.cli nickname clear --user @alice@example.com         # remove it
+python -m prami.cli relationship --user @alice@example.com           # inspect (dev only)
+```
+
+Nicknames are sanitized (max 32 chars, no URLs, mentions, HTML, or line breaks) and have a
+short cooldown. Prami uses a set nickname occasionally in replies, a little more often for
+high-bond users. Raw relationship numbers only show in `relationship`, never in normal
+replies.
+
+## Social features (teach, events, admin)
+
+These need their flags on (`ENABLE_TEACH_COMMAND`, `ENABLE_EVENTS`). The CLI grants you local
+admin. Full reference: [LOCAL_TESTING.md](LOCAL_TESTING.md).
+
+```bash
+python -m prami.cli teach '"Prami believes soup is weather."' --user @alice@example.com
+python -m prami.cli admin memories
+python -m prami.cli admin approve-memory 1
+python -m prami.cli event-start snack_vote
+python -m prami.cli vote soup --user @alice@example.com
+python -m prami.cli event-complete
+python -m prami.cli admin status
+```
+
+When an action is favourite/boost-worthy or hits a milestone, the CLI notes it under the reply.
+
+## Interactive shell
+
+```bash
+python -m prami.cli shell
+```
+
+Then type interactions as if they were mentions:
+
+```
+@alice feed
+@bob play
+@alice status
+@charlie pet
+tick 2h
+debug
+exit
+```
+
+You can also set a nickname (`@alice call me Soup Wizard`) and inspect a user with
+`whois @alice`.
+
+Inside the shell: `tick`, `tick 30m`, `tick 3h` simulate time; `debug` prints raw state;
+`exit` or `quit` leaves.
+
+## Inspect raw state (dev only)
+
+```bash
+python -m prami.cli debug-state
+```
+
+Prints the raw numbers — hunger, happiness, energy, health, cleanliness, social, trust,
+chaos, asleep, mood and the last updated timestamp. Normal users only ever see the
+narrative `status`, never these numbers.
+
+## Simulate time passing
+
+```bash
+python -m prami.cli tick                 # one decay step
+python -m prami.cli tick --hours 3
+python -m prami.cli tick --minutes 30
+```
+
+This applies the exact same decay logic the scheduler runs. One step equals
+`STATE_DECAY_INTERVAL_MINUTES` of real time, so `--hours 3` applies as many steps as the
+scheduler would over three hours.
+
+## Reset
+
+```bash
+python -m prami.cli reset          # asks for confirmation
+python -m prami.cli reset --yes    # no prompt
+```
+
+Resets the pet to defaults and clears interactions, cooldowns, events and processed
+statuses. User records are kept.
+
+## Inside Docker Compose
+
+The same commands work in the container, against the Compose Postgres:
+
+```bash
+docker compose run --rm prami python -m prami.cli status
+docker compose run --rm prami python -m prami.cli feed --user @alice@example.com
+docker compose run --rm prami python -m prami.cli shell
+docker compose run --rm prami python -m prami.cli tick --hours 4
+docker compose run --rm prami python -m prami.cli debug-state
+```
@@ -0,0 +1,133 @@+# Teste local (sem precisar de Mastodon)
+
+O Prami vem com um CLI local para você brincar com o bicho como engine de jogo e de
+personalidade antes de conectá-lo ao Mastodon. O CLI usa o **mesmo** parser, game engine,
+cooldowns, banco de dados e gerador de respostas que o bot usa — não é um mock separado.
+
+Por padrão ele usa o `DATABASE_URL` configurado (SQLite é ótimo localmente):
+
+```bash
+export DATABASE_URL="sqlite:///prami.db"   # PowerShell no Windows: $env:DATABASE_URL="sqlite:///prami.db"
+```
+
+> Os comandos do Prami são em inglês (é o que o parser entende). Esta documentação em
+> português explica como usá-los.
+
+## Comandos avulsos
+
+```bash
+python -m prami.cli status
+python -m prami.cli feed  --user @alice@example.com
+python -m prami.cli play  --user @bob@example.com
+python -m prami.cli pet   --user @alice@example.com
+python -m prami.cli clean --user @alice@example.com
+python -m prami.cli sleep --user @alice@example.com
+python -m prami.cli wake  --user @alice@example.com
+python -m prami.cli help
+```
+
+O `--user` é opcional; sem ele, um usuário local padrão é usado. Cada usuário tem seus
+próprios cooldowns, como contas de verdade.
+
+## Apelidos e relacionamento
+
+O Prami guarda dados leves e só de jogo sobre cada usuário (contagem de interações, um
+trust score por usuário e um nível de vínculo: stranger → familiar → trusted → friend →
+beloved menace). Usuários gentis e frequentes criam vínculo mais rápido; ajudar num estado
+crítico vale confiança extra.
+
+```bash
+python -m prami.cli nickname Soup Wizard --user @alice@example.com   # define apelido
+python -m prami.cli nickname clear --user @alice@example.com         # remove o apelido
+python -m prami.cli relationship --user @alice@example.com           # inspeciona (só dev)
+```
+
+Apelidos são sanitizados (máx. 32 caracteres, sem URLs, menções, HTML ou quebras de linha)
+e têm um cooldown curto. O Prami usa o apelido de vez em quando nas respostas, um pouco mais
+para usuários de vínculo alto. Os números crus de relacionamento só aparecem no
+`relationship`, nunca nas respostas normais.
+
+## Recursos sociais (teach, eventos, admin)
+
+Precisam das flags ligadas (`ENABLE_TEACH_COMMAND`, `ENABLE_EVENTS`). O CLI te concede admin
+local. Referência completa: [LOCAL_TESTING.md](LOCAL_TESTING.md).
+
+```bash
+python -m prami.cli teach '"Prami believes soup is weather."' --user @alice@example.com
+python -m prami.cli admin memories
+python -m prami.cli admin approve-memory 1
+python -m prami.cli event-start snack_vote
+python -m prami.cli vote soup --user @alice@example.com
+python -m prami.cli event-complete
+python -m prami.cli admin status
+```
+
+Quando uma ação é favourite/boost-worthy ou bate um milestone, o CLI mostra isso abaixo da resposta.
+
+## Shell interativo
+
+```bash
+python -m prami.cli shell
+```
+
+Aí é só digitar as interações como se fossem menções:
+
+```
+@alice feed
+@bob play
+@alice status
+@charlie pet
+tick 2h
+debug
+exit
+```
+
+Você também pode definir um apelido (`@alice call me Soup Wizard`) e inspecionar um usuário
+com `whois @alice`.
+
+Dentro do shell: `tick`, `tick 30m`, `tick 3h` simulam o tempo; `debug` mostra o estado
+bruto; `exit` ou `quit` sai.
+
+## Inspecionar o estado bruto (só para dev)
+
+```bash
+python -m prami.cli debug-state
+```
+
+Mostra os números crus — fome, felicidade, energia, saúde, limpeza, sociabilidade,
+confiança, caos, dormindo, humor e o timestamp da última atualização. Usuários normais só
+veem o `status` narrativo, nunca esses números.
+
+## Simular a passagem do tempo
+
+```bash
+python -m prami.cli tick                 # um passo de decay
+python -m prami.cli tick --hours 3
+python -m prami.cli tick --minutes 30
+```
+
+Aplica exatamente a mesma lógica de decay que o scheduler roda. Um passo equivale a
+`STATE_DECAY_INTERVAL_MINUTES` de tempo real, então `--hours 3` aplica a mesma quantidade
+de passos que o scheduler aplicaria em três horas.
+
+## Reset
+
+```bash
+python -m prami.cli reset          # pede confirmação
+python -m prami.cli reset --yes    # sem prompt
+```
+
+Volta o bicho aos valores padrão e limpa interações, cooldowns, eventos e status
+processados. Os registros de usuários são mantidos.
+
+## Dentro do Docker Compose
+
+Os mesmos comandos funcionam no contêiner, contra o Postgres do Compose:
+
+```bash
+docker compose run --rm prami python -m prami.cli status
+docker compose run --rm prami python -m prami.cli feed --user @alice@example.com
+docker compose run --rm prami python -m prami.cli shell
+docker compose run --rm prami python -m prami.cli tick --hours 4
+docker compose run --rm prami python -m prami.cli debug-state
+```
@@ -0,0 +1,47 @@+# Prami's personality
+
+Prami is meant to read like a tiny social creature, not a chatbot: cute, strange,
+affectionate, dramatic, a little suspicious, occasionally chaotic, never cruel. Replies are
+short (1–4 sentences); only `status` runs a touch longer.
+
+## How a reply is chosen
+
+Every reply is a handcrafted template — no generative AI. For each command there are named
+template groups, and Prami's current state decides which group to draw from, then picks a
+line at random for variety.
+
+| Command | Groups |
+|---|---|
+| status | normal, hungry, tired, happy, sad, chaotic, dirty, asleep |
+| feed | normal, very_hungry, already_full, suspicious, happy, chaotic |
+| play | normal, energetic, tired, chaotic, asleep |
+| pet | normal, affectionate, low_trust, high_trust, annoyed, asleep |
+| clean | normal, dirty, already_clean, angry, chaotic |
+| sleep | normal, already_asleep, too_energetic, grateful |
+| wake | normal, already_awake, too_tired, annoyed |
+
+Action replies are chosen from the state **before** the command is applied, so feeding a
+starving Prami sounds desperate, and feeding a full one sounds unimpressed.
+
+Autonomous posts have their own groups: hungry, lonely, sleepy, chaotic, happy, dirty,
+existential, community_appreciation, weird_observation. The current state picks the base
+group; sometimes Prami drifts into an existential or weird-observation post instead.
+
+## Personality traits
+
+`PET_PERSONALITY` is free text. Recognised trait words tune the thresholds that pick
+groups:
+
+- **chaotic** / **calm** — how easily Prami tips into weirder, chaotic lines
+- **suspicious** / **affectionate** / **clingy** — how guarded the care responses are
+- **dramatic** — how readily it gets sleepy and theatrical
+- **affectionate** — a lower bar for happy/affectionate responses
+
+So `PET_PERSONALITY=chaotic, weird` feels noticeably more feral than
+`PET_PERSONALITY=calm, shy`, with the same code and templates.
+
+## Personality packs (future)
+
+Templates live in a pack registry keyed by name (`default` ships today). `PET_PERSONALITY_PACK`
+selects which pack is active. Adding a new pack later is just registering another set of
+template groups — the selection logic stays the same.
@@ -0,0 +1,50 @@+# A personalidade do Prami
+
+O Prami foi feito para soar como um bichinho social, não como um chatbot: fofo, estranho,
+afetuoso, dramático, um pouco desconfiado, ocasionalmente caótico, nunca cruel. As respostas
+são curtas (1 a 4 frases); só o `status` é um pouco mais longo.
+
+> Os textos do Prami são sempre em inglês. Esta documentação em português explica como o
+> sistema funciona.
+
+## Como uma resposta é escolhida
+
+Toda resposta é um template feito à mão — sem IA generativa. Cada comando tem grupos de
+templates nomeados, e o estado atual do Prami decide de qual grupo puxar; depois sorteia
+uma frase do grupo para dar variedade.
+
+| Comando | Grupos |
+|---|---|
+| status | normal, hungry, tired, happy, sad, chaotic, dirty, asleep |
+| feed | normal, very_hungry, already_full, suspicious, happy, chaotic |
+| play | normal, energetic, tired, chaotic, asleep |
+| pet | normal, affectionate, low_trust, high_trust, annoyed, asleep |
+| clean | normal, dirty, already_clean, angry, chaotic |
+| sleep | normal, already_asleep, too_energetic, grateful |
+| wake | normal, already_awake, too_tired, annoyed |
+
+As falas de ação usam o estado **antes** de o comando ser aplicado, então alimentar um
+Prami faminto soa desesperado, e alimentar um já cheio soa entediado.
+
+Os posts autônomos têm grupos próprios: hungry, lonely, sleepy, chaotic, happy, dirty,
+existential, community_appreciation, weird_observation. O estado atual escolhe o grupo base;
+às vezes o Prami escapa para um post existencial ou de observação estranha.
+
+## Traços de personalidade
+
+`PET_PERSONALITY` é texto livre. Palavras de traço reconhecidas ajustam os limiares que
+escolhem os grupos:
+
+- **chaotic** / **calm** — com que facilidade o Prami pende para falas mais esquisitas/caóticas
+- **suspicious** / **affectionate** / **clingy** — quão desconfiadas são as respostas de carinho
+- **dramatic** — com que facilidade fica sonolento e teatral
+- **affectionate** — barra mais baixa para respostas felizes/afetuosas
+
+Então `PET_PERSONALITY=chaotic, weird` soa bem mais feral que `PET_PERSONALITY=calm, shy`,
+com o mesmo código e os mesmos templates.
+
+## Personality packs (futuro)
+
+Os templates ficam num registro de packs por nome (`default` é o que existe hoje).
+`PET_PERSONALITY_PACK` seleciona o pack ativo. Adicionar um novo pack depois é só registrar
+outro conjunto de grupos de templates — a lógica de seleção continua a mesma.
@@ -0,0 +1,65 @@+# Using Prami
+
+Prami is a shared community pet. There is one creature, and everyone looks after it
+together by mentioning its Mastodon account.
+
+## Commands
+
+Mention the bot and include one of these words (case does not matter):
+
+| Command | What it does |
+|---|---|
+| `status` | See how Prami is doing right now |
+| `feed` | Give Prami food |
+| `play` | Play with Prami |
+| `pet` | A little affection |
+| `clean` | Clean Prami (it will resent this) |
+| `sleep` | Put Prami to bed |
+| `wake` | Wake Prami up |
+| `help` | List the commands |
+
+Example:
+
+```
+@prami feed
+@prami status
+```
+
+A few aliases also work: `food`, `eat`, `pat`, `cuddle`, `bath`, `wash`, `nap`,
+`wakeup`, `?`.
+
+## What Prami tracks
+
+Hunger, happiness, energy, health, cleanliness, social, trust, and chaos — plus whether
+it is asleep, its age in days, and a mood derived from all of the above. These values
+drift on their own over time: Prami gets hungry, tired, dirty and lonely, so the community
+has to keep up. If hunger, cleanliness or energy stay bad for too long, its health starts
+to drop.
+
+## Cooldowns
+
+So that one person — or a busy timeline — can't run the pet ragged, each command has a
+limit:
+
+| Command | Per user | Globally |
+|---|---|---|
+| feed | once / 30 min | 10 / hour |
+| play | once / 20 min | 15 / hour |
+| pet | once / 10 min | 30 / hour |
+| clean | once / 60 min | 5 / hour |
+| sleep | — | once / 120 min |
+| wake | — | once / 60 min |
+
+There is also an overall cap per user per hour and a global cap per hour.
+
+## Sleep
+
+While Prami is asleep, `feed`, `play` and `clean` are gently declined until someone uses
+`wake`. Waking it while its energy is very low will get you a complaint, but it will
+still wake up.
+
+## Replies and autonomous posts
+
+Replies are unlisted by default so the public timeline stays clean. Prami also posts on
+its own from time to time, based on its current mood — also unlisted by default. Both
+visibilities are configurable.
@@ -0,0 +1,68 @@+# Usando o Prami
+
+O Prami é um bicho de estimação comunitário e compartilhado. Existe uma única criatura, e
+todo mundo cuida dela em conjunto mencionando a conta dele no Mastodon.
+
+> Os comandos são sempre em inglês — o Prami só entende essas palavras. Esta documentação
+> em português explica como usá-los.
+
+## Comandos
+
+Mencione o bot e inclua uma destas palavras (maiúsculas/minúsculas não importam):
+
+| Comando | O que faz |
+|---|---|
+| `status` | Mostra como o Prami está agora |
+| `feed` | Dá comida ao Prami |
+| `play` | Brinca com o Prami |
+| `pet` | Um carinho rápido |
+| `clean` | Dá banho no Prami (ele vai ficar magoado) |
+| `sleep` | Coloca o Prami para dormir |
+| `wake` | Acorda o Prami |
+| `help` | Lista os comandos |
+
+Exemplo:
+
+```
+@prami feed
+@prami status
+```
+
+Alguns apelidos também funcionam: `food`, `eat`, `pat`, `cuddle`, `bath`, `wash`, `nap`,
+`wakeup`, `?`.
+
+## O que o Prami acompanha
+
+Fome, felicidade, energia, saúde, limpeza, sociabilidade, confiança e caos — além de estar
+dormindo ou não, a idade em dias e um humor derivado de tudo isso. Esses valores mudam
+sozinhos com o tempo: o Prami fica com fome, cansado, sujo e carente, então a comunidade
+precisa acompanhar. Se fome, limpeza ou energia ficarem ruins por tempo demais, a saúde
+dele começa a cair.
+
+## Tempos de espera (cooldowns)
+
+Para que uma pessoa só — ou uma timeline movimentada — não esgote o bichinho, cada comando
+tem um limite:
+
+| Comando | Por usuário | No geral |
+|---|---|---|
+| feed | 1x / 30 min | 10 / hora |
+| play | 1x / 20 min | 15 / hora |
+| pet | 1x / 10 min | 30 / hora |
+| clean | 1x / 60 min | 5 / hora |
+| sleep | — | 1x / 120 min |
+| wake | — | 1x / 60 min |
+
+Há também um limite total por usuário por hora e um limite global por hora.
+
+## Sono
+
+Enquanto o Prami está dormindo, `feed`, `play` e `clean` são recusados com gentileza até
+alguém usar `wake`. Acordá-lo com a energia muito baixa rende uma reclamação, mas ele
+acorda mesmo assim.
+
+## Respostas e posts autônomos
+
+As respostas são `unlisted` (não listadas) por padrão, para não poluir a timeline pública.
+O Prami também posta sozinho de vez em quando, conforme o humor do momento — também
+`unlisted` por padrão. As duas visibilidades são configuráveis.
@@ -0,0 +1,1 @@+__version__ = "0.1.0"
@@ -0,0 +1,33 @@+import logging
+
+import prami
+from .app import Poller, bootstrap
+from .config import config
+from .scheduler import build_scheduler
+
+
+def main():
+    logging.basicConfig(
+        level=config.log_level.upper(),
+        format="%(asctime)s %(levelname)-7s %(name)s | %(message)s",
+        datefmt="%Y-%m-%d %H:%M:%S",
+    )
+    logging.getLogger("apscheduler").setLevel(logging.WARNING)
+
+    log = logging.getLogger("prami")
+    log.info("Starting Prami %s", prami.__version__)
+
+    client = bootstrap()
+    scheduler = build_scheduler(client)
+    scheduler.start()
+
+    try:
+        Poller(client).run()
+    except KeyboardInterrupt:
+        log.info("Shutting down, see you soon")
+    finally:
+        scheduler.shutdown(wait=False)
+
+
+if __name__ == "__main__":
+    main()
@@ -0,0 +1,3 @@+from .registry import REGISTRY, ActionRegistry, build_registry
+
+__all__ = ["REGISTRY", "ActionRegistry", "build_registry"]
@@ -0,0 +1,10 @@+from .. import admin as admin_mod
+from .base import Action
+
+
+class AdminAction(Action):
+    name = "admin"
+    admin_only = True
+
+    def run(self, ctx):
+        return admin_mod.run(ctx)
@@ -0,0 +1,48 @@+from dataclasses import dataclass
+
+
+@dataclass
+class ActionContext:
+    session: object
+    pet: object
+    user: object
+    arg: str
+    config: object
+    settings: object
+    now: object
+    status_id: object = None
+
+
+@dataclass
+class Outcome:
+    text: str
+    accepted: bool = True
+    record: bool = True
+    address: bool = True
+    favourite: bool = False
+    boost: bool = False
+    was_critical: bool = False
+    visibility: str = None
+
+
+class Action:
+    name = ""
+    aliases = ()
+    phrase_aliases = ()
+    admin_only = False
+    runs_while_asleep = True
+    user_cooldown_minutes = 0
+    global_cooldown_minutes = 0
+    per_hour_limit = None
+
+    def enabled(self, config):
+        return True
+
+    def on_disabled(self, ctx):
+        return Outcome(f"{ctx.pet.name} can't do that right now — it's turned off.", accepted=False, record=False)
+
+    def precheck(self, ctx):
+        return None
+
+    def run(self, ctx):
+        raise NotImplementedError
@@ -0,0 +1,125 @@+from .. import relationships, responses, social
+from ..state import clamp, compute_mood
+from .base import Action, Outcome
+
+
+def _snapshot(pet):
+    return {
+        "hunger": pet.hunger,
+        "social": pet.social,
+        "cleanliness": pet.cleanliness,
+        "health": pet.health,
+        "energy": pet.energy,
+        "chaos": pet.chaos,
+    }
+
+
+class CareAction(Action):
+    def effect(self, pet):
+        raise NotImplementedError
+
+    def run(self, ctx):
+        pet = ctx.pet
+        text = responses.for_command(self.name, pet)
+        before = _snapshot(pet)
+        before_critical = relationships.is_critical(pet)
+
+        self.effect(pet)
+        pet.mood = compute_mood(pet)
+
+        return Outcome(
+            text=text,
+            favourite=social.favourite_worthy(self.name, before, ctx.user),
+            boost=social.boost_worthy(before_critical, relationships.is_critical(pet)),
+            was_critical=before_critical,
+        )
+
+
+class FeedAction(CareAction):
+    name = "feed"
+    aliases = ("food", "eat", "snack", "feast")
+    runs_while_asleep = False
+    user_cooldown_minutes = 30
+    per_hour_limit = 10
+
+    def effect(self, pet):
+        was_hungry = pet.hunger > 60
+        pet.hunger = clamp(pet.hunger - 35)
+        pet.happiness = clamp(pet.happiness + 5)
+        if was_hungry:
+            pet.health = clamp(pet.health + 3)
+        if pet.hunger < 10:
+            pet.chaos = clamp(pet.chaos + 8)
+            pet.happiness = clamp(pet.happiness - 5)
+
+
+class PlayAction(CareAction):
+    name = "play"
+    aliases = ("fun", "games", "toy")
+    runs_while_asleep = False
+    user_cooldown_minutes = 20
+    per_hour_limit = 15
+
+    def effect(self, pet):
+        pet.happiness = clamp(pet.happiness + 12)
+        pet.social = clamp(pet.social + 10)
+        pet.energy = clamp(pet.energy - 15)
+        pet.chaos = clamp(pet.chaos + 5)
+
+
+class PetAction(CareAction):
+    name = "pet"
+    aliases = ("pat", "pats", "cuddle", "scritch", "scratch", "boop")
+    user_cooldown_minutes = 10
+    per_hour_limit = 30
+
+    def effect(self, pet):
+        pet.trust = clamp(pet.trust + 6)
+        pet.social = clamp(pet.social + 5)
+        pet.happiness = clamp(pet.happiness + 6)
+        pet.energy = clamp(pet.energy - 2)
+
+
+class CleanAction(CareAction):
+    name = "clean"
+    aliases = ("bath", "wash", "scrub", "tidy")
+    runs_while_asleep = False
+    user_cooldown_minutes = 60
+    per_hour_limit = 5
+
+    def effect(self, pet):
+        pet.cleanliness = clamp(pet.cleanliness + 40)
+        pet.happiness = clamp(pet.happiness - 6)
+        pet.chaos = clamp(pet.chaos + 3)
+
+
+class SleepAction(CareAction):
+    name = "sleep"
+    aliases = ("nap", "bed", "rest")
+    global_cooldown_minutes = 120
+
+    def precheck(self, ctx):
+        if ctx.pet.asleep:
+            return Outcome(responses.already_asleep(ctx.pet), accepted=False, record=False)
+        return None
+
+    def effect(self, pet):
+        pet.asleep = True
+        pet.happiness = clamp(pet.happiness + 2)
+
+
+class WakeAction(CareAction):
+    name = "wake"
+    aliases = ("wakeup", "awake", "rise")
+    global_cooldown_minutes = 60
+
+    def precheck(self, ctx):
+        if not ctx.pet.asleep:
+            return Outcome(responses.already_awake(ctx.pet), accepted=False, record=False)
+        return None
+
+    def effect(self, pet):
+        pet.asleep = False
+        if pet.energy < 20:
+            pet.happiness = clamp(pet.happiness - 3)
+            pet.chaos = clamp(pet.chaos + 4)
@@ -0,0 +1,40 @@+from .. import events as events_mod
+from .. import responses
+from .base import Action, Outcome
+
+
+class EventAction(Action):
+    name = "event"
+
+    def run(self, ctx):
+        event = events_mod.active(ctx.session, ctx.now)
+        if event is None:
+            return Outcome(responses.no_event(ctx.pet), record=False)
+        text = responses.event_show(ctx.pet, event.title, event.description, events_mod.options_text(event))
+        return Outcome(text, record=False)
+
+
+class EventHelpAction(Action):
+    name = "helpevent"
+    aliases = ("help-event",)
+
+    def run(self, ctx):
+        event = events_mod.active(ctx.session, ctx.now)
+        if event is None:
+            return Outcome(responses.no_event(ctx.pet), record=False)
+        text = responses.event_help(ctx.pet, event.title, events_mod.options_text(event))
+        return Outcome(text, record=False)
+
+
+class VoteAction(Action):
+    name = "vote"
+
+    def run(self, ctx):
+        event = events_mod.active(ctx.session, ctx.now)
+        if event is None:
+            return Outcome(responses.no_event(ctx.pet), accepted=False, record=False)
+        ok, reason = events_mod.vote(ctx.session, event, ctx.user, ctx.arg)
+        if not ok:
+            text = responses.vote_rejected(ctx.pet, reason, events_mod.options_text(event))
+            return Outcome(text, accepted=False, record=False)
+        return Outcome(responses.vote_ack(ctx.pet, event.title))
@@ -0,0 +1,10 @@+from .. import responses
+from .base import Action, Outcome
+
+
+class HelpAction(Action):
+    name = "help"
+    aliases = ("commands", "menu", "?")
+
+    def run(self, ctx):
+        return Outcome(responses.help_text(ctx.config))
@@ -0,0 +1,22 @@+from .. import responses, sanitize
+from .base import Action, Outcome
+
+
+class NicknameAction(Action):
+    name = "nickname"
+    aliases = ("nick",)
+    phrase_aliases = ("name me", "call me")
+    user_cooldown_minutes = 5
+
+    def run(self, ctx):
+        arg = ctx.arg.strip()
+        if arg.lower() == "clear":
+            ctx.user.nickname = None
+            return Outcome(responses.nickname_cleared(ctx.pet), address=False)
+
+        cleaned = sanitize.clean_nickname(arg)
+        if not cleaned:
+            return Outcome(responses.nickname_rejected(ctx.pet), accepted=False, record=False, address=False)
+
+        ctx.user.nickname = cleaned
+        return Outcome(responses.nickname_set(ctx.pet, cleaned), address=False)
@@ -0,0 +1,57 @@+class ActionRegistry:
+    def __init__(self):
+        self._by_name = {}
+        self._by_alias = {}
+        self._phrases = {}
+
+    def register(self, action):
+        self._by_name[action.name] = action
+        for alias in (action.name, *action.aliases):
+            self._by_alias[alias.lower()] = action.name
+        for phrase in action.phrase_aliases:
+            self._phrases[phrase.lower()] = action.name
+
+    def resolve(self, command):
+        return self._by_name.get(command) or self._by_name["unknown"]
+
+    def canonical(self, token):
+        return self._by_alias.get((token or "").lower())
+
+    def alias_map(self):
+        return dict(self._by_alias)
+
+    def phrase_map(self):
+        return dict(self._phrases)
+
+    def is_enabled(self, command, config):
+        action = self._by_name.get(command)
+        return bool(action and action.enabled(config))
+
+    def names(self):
+        return list(self._by_name)
+
+
+def build_registry():
+    from .admin import AdminAction
+    from .care import CleanAction, FeedAction, PetAction, PlayAction, SleepAction, WakeAction
+    from .events import EventAction, EventHelpAction, VoteAction
+    from .help import HelpAction
+    from .nickname import NicknameAction
+    from .status import StatusAction
+    from .teach import TeachAction
+    from .unknown import UnknownAction
+
+    registry = ActionRegistry()
+    actions = (
+        StatusAction, HelpAction, UnknownAction,
+        FeedAction, PlayAction, PetAction, CleanAction, SleepAction, WakeAction,
+        NicknameAction, TeachAction,
+        EventAction, EventHelpAction, VoteAction,
+        AdminAction,
+    )
+    for action_cls in actions:
+        registry.register(action_cls())
+    return registry
+
+
+REGISTRY = build_registry()
@@ -0,0 +1,10 @@+from .. import responses
+from .base import Action, Outcome
+
+
+class StatusAction(Action):
+    name = "status"
+    aliases = ("stat", "stats", "how", "howareyou", "check")
+
+    def run(self, ctx):
+        return Outcome(responses.status(ctx.pet))
@@ -0,0 +1,20 @@+from .. import memory, responses
+from .base import Action, Outcome
+
+
+class TeachAction(Action):
+    name = "teach"
+    aliases = ("remember", "learn")
+    user_cooldown_minutes = 10
+
+    def enabled(self, config):
+        return config.enable_teach_command
+
+    def on_disabled(self, ctx):
+        return Outcome(responses.teach_disabled(ctx.pet), accepted=False, record=False)
+
+    def run(self, ctx):
+        suggestion = memory.submit(ctx.session, ctx.pet, ctx.user, ctx.arg, ctx.config)
+        if suggestion is None:
+            return Outcome(responses.teach_rejected(ctx.pet), accepted=False, record=False)
+        return Outcome(responses.teach_ack(ctx.pet))
@@ -0,0 +1,9 @@+from .. import responses
+from .base import Action, Outcome
+
+
+class UnknownAction(Action):
+    name = "unknown"
+
+    def run(self, ctx):
+        return Outcome(responses.unknown(ctx.pet))
@@ -0,0 +1,135 @@+from . import memory
+from . import repository as repo
+from . import stats
+from .actions.base import Outcome
+
+VISIBILITIES = ("public", "unlisted", "private", "direct")
+ADMIN_VISIBILITY = "direct"
+
+
+def run(ctx):
+    config, user = ctx.config, ctx.user
+    if not config.is_admin(user.acct):
+        if config.admin_refuse_nonadmin:
+            return Outcome(
+                "That's an admin-only command.",
+                accepted=False, record=False, address=False, visibility=ADMIN_VISIBILITY,
+            )
+        return None
+
+    parts = ctx.arg.split()
+    sub = parts[0].lower() if parts else "help"
+    rest = " ".join(parts[1:]).strip()
+
+    if sub == "stats":
+        text = stats.text_report(ctx.session, ctx.config, ctx.settings, ctx.now)
+    else:
+        text = _dispatch(ctx.session, ctx.pet, ctx.settings, sub, rest)
+    repo.log_event(ctx.session, ctx.pet.id, "admin", f"{user.acct}: {ctx.arg}".strip())
+    return Outcome(text, record=False, address=False, visibility=ADMIN_VISIBILITY)
+
+
+def _dispatch(session, pet, settings, sub, rest):
+    if sub == "status":
+        return _status(session, settings)
+    if sub == "pause":
+        settings.paused = True
+        return "Prami is paused — it will stop reacting until you resume it."
+    if sub == "resume":
+        settings.paused = False
+        return "Prami is back. It missed you, probably."
+    if sub == "set-visibility":
+        if rest in VISIBILITIES:
+            settings.default_visibility = rest
+            return f"Default reply visibility set to {rest}."
+        return f"Visibility must be one of: {', '.join(VISIBILITIES)}."
+    if sub == "set-autopost":
+        settings.autopost_enabled = _on(rest)
+        return f"Autoposts {_state(settings.autopost_enabled)}."
+    if sub == "set-favourites":
+        settings.favourites_enabled = _on(rest)
+        return f"Favourites {_state(settings.favourites_enabled)}."
+    if sub == "set-boosts":
+        settings.boosts_enabled = _on(rest)
+        return f"Boosts {_state(settings.boosts_enabled)}."
+    if sub == "blocked-users":
+        return _blocked(session)
+    if sub == "block" and rest:
+        repo.block_user(session, rest)
+        return f"Blocked {rest.lstrip('@')}."
+    if sub == "unblock" and rest:
+        target = repo.get_user(session, rest)
+        if target:
+            target.blocked = False
+        return f"Unblocked {rest.lstrip('@')}."
+    if sub == "memories" or (sub == "memory-list" and rest in ("", "pending")):
+        return _memory_list(session, "pending")
+    if sub == "memory-list":
+        return _memory_list(session, rest)
+    if sub == "approve-memory" and rest:
+        return _review(session, rest, approve=True)
+    if sub == "reject-memory" and rest:
+        return _review(session, rest, approve=False)
+    return _help()
+
+
+def _on(value):
+    return value.strip().lower() in ("on", "true", "yes", "1", "enable", "enabled")
+
+
+def _state(enabled):
+    return "enabled" if enabled else "disabled"
+
+
+def _status(session, settings):
+    pending = len(memory.by_status(session, "pending"))
+    approved = len(memory.by_status(session, "approved"))
+    return (
+        f"paused={settings.paused} · autopost={_state(settings.autopost_enabled)} · "
+        f"favourites={_state(settings.favourites_enabled)} · boosts={_state(settings.boosts_enabled)} · "
+        f"visibility={settings.default_visibility} · memories: {pending} pending, {approved} approved"
+    )
+
+
+def _blocked(session):
+    from .models import User
+    from sqlalchemy import select
+
+    blocked = list(session.scalars(select(User).where(User.blocked.is_(True))))
+    if not blocked:
+        return "No blocked users."
+    return "Blocked: " + ", ".join(u.acct for u in blocked)
+
+
+def _memory_list(session, status):
+    status = status if status in ("pending", "approved", "rejected") else "pending"
+    items = memory.by_status(session, status)
+    if not items:
+        return f"No {status} memories."
+    lines = [f"#{m.id} [{m.category}] {m.content}" for m in items[:10]]
+    return f"{status.capitalize()} memories:\n" + "\n".join(lines)
+
+
+def _review(session, rest, approve):
+    parts = rest.split(None, 1)
+    try:
+        suggestion_id = int(parts[0])
+    except ValueError:
+        return "Usage: approve-memory <id> | reject-memory <id> [reason]"
+
+    if approve:
+        result = memory.approve(session, suggestion_id, "admin")
+        return f"Approved memory #{suggestion_id}." if result else f"No pending memory #{suggestion_id}."
+
+    reason = parts[1] if len(parts) > 1 else ""
+    result = memory.reject(session, suggestion_id, "admin", reason)
+    return f"Rejected memory #{suggestion_id}." if result else f"No pending memory #{suggestion_id}."
+
+
+def _help():
+    return (
+        "Admin commands: status · stats · pause · resume · set-visibility <v> · "
+        "set-autopost on|off · set-favourites on|off · set-boosts on|off · "
+        "blocked-users · block <user> · unblock <user> · memories · "
+        "memory-list pending|approved · approve-memory <id> · reject-memory <id> [reason]"
+    )
@@ -0,0 +1,135 @@+import logging
+import time
+
+from . import engine
+from . import repository as repo
+from . import safety
+from .config import config
+from .db import create_all, init_engine, session_scope, wait_for_db
+from .mastodon_client import PramiClient
+from .parser import parse
+
+log = logging.getLogger("prami.app")
+
+
+def bootstrap():
+    init_engine(config.database_url)
+    wait_for_db()
+    create_all()
+
+    with session_scope() as session:
+        pet = repo.get_or_create_pet(session, config)
+        for acct in config.blocked_users:
+            repo.block_user(session, acct)
+        repo.log_event(session, pet.id, "startup", "prami started")
+        log.info("Pet ready: %s the %s (day %s, mood %s)", pet.name, pet.species, pet.age_days, pet.mood)
+
+    return PramiClient(config)
+
+
+class Poller:
+    def __init__(self, client):
+        self.client = client
+        self.last_id = None
+        self.bot_acct = (config.bot_acct or client.me.acct).lstrip("@")
+        self.bot_id = client.me.id
+
+    def poll_once(self):
+        mentions = self.client.fetch_mentions(since_id=self.last_id)
+        for notif in reversed(mentions):
+            if self.last_id is None or notif.id > self.last_id:
+                self.last_id = notif.id
+            self._handle(notif)
+
+    def _handle(self, notif):
+        status = getattr(notif, "status", None)
+        if status is None:
+            return
+
+        account = notif.account
+        if account.id == self.bot_id or account.acct.lstrip("@") == self.bot_acct:
+            return
+
+        sid = str(status.id)
+        with session_scope() as session:
+            if repo.is_status_processed(session, sid):
+                return
+            repo.mark_status_processed(session, sid)
+
+            user = repo.get_or_create_user(
+                session,
+                acct=account.acct,
+                mastodon_id=str(account.id),
+                display_name=account.display_name or "",
+            )
+            pet = repo.get_pet(session)
+            parsed = parse(status.content)
+            command = parsed.name
+            result = engine.handle(
+                session, pet, user, command, config, status_id=sid, arg=parsed.arg
+            )
+
+        if result is None:
+            return
+
+        try:
+            self.client.reply(status, f"@{account.acct} {result.text}", result.visibility)
+            log.info("Replied to @%s (%s, accepted=%s)", account.acct, command, result.accepted)
+        except Exception:
+            log.exception("Failed to reply to @%s", account.acct)
+
+        if result.boost:
+            self._react(status, account, "boost")
+        elif result.favourite:
+            self._react(status, account, "favourite")
+
+        if result.milestones:
+            self._announce_milestones(result.milestones, status, account)
+
+    def _announce_milestones(self, achieved, status, account):
+        for milestone in achieved:
+            if config.enable_milestone_posts:
+                try:
+                    self.client.post(milestone["text"], config.milestone_post_visibility)
+                except Exception:
+                    log.exception("Failed to post milestone %s", milestone["key"])
+            if milestone.get("boost"):
+                self._react(status, account, "boost")
+            elif milestone.get("favourite"):
+                self._react(status, account, "favourite")
+
+    def _react(self, status, account, action):
+        sid = str(status.id)
+        visibility = getattr(status, "visibility", "public")
+        with session_scope() as session:
+            settings = repo.get_settings(session, config)
+            user = repo.get_user(session, account.acct)
+            check = safety.may_boost if action == "boost" else safety.may_favourite
+            allowed, reason = check(session, settings, config, user, visibility, sid)
+        if not allowed:
+            log.info("Skipped %s of %s: %s", action, sid, reason)
+            return
+
+        try:
+            (self.client.boost if action == "boost" else self.client.favourite)(status)
+        except Exception:
+            log.exception("Failed to %s %s", action, sid)
+            return
+
+        with session_scope() as session:
+            if not repo.has_social_action(session, sid, action):
+                pet = repo.get_pet(session)
+                user = repo.get_user(session, account.acct)
+                uid = user.id if user else None
+                repo.record_social_action(session, pet.id, uid, sid, action, "worthy interaction")
+                repo.log_event(session, pet.id, action, f"{action} of {sid} by @{account.acct}")
+        log.info("%s %s by @%s", action.capitalize(), sid, account.acct)
+
+    def run(self):
+        log.info("Listening for mentions every %ss", config.poll_interval)
+        while True:
+            try:
+                self.poll_once()
+            except Exception:
+                log.exception("Polling cycle failed")
+            time.sleep(config.poll_interval)
@@ -0,0 +1,45 @@+import random
+
+from . import events
+from . import memory
+from . import responses
+from . import templates
+from .models import utcnow
+
+FLAVOR = ("weird_observation", "existential", "community_appreciation")
+
+
+def choose_category(session, pet, config):
+    options = [responses.autopost_group(pet), *FLAVOR]
+
+    if config.enable_events and events.active(session) is not None:
+        options += ["active_event_reminder", "active_event_reminder"]
+    if memory.by_status(session, "approved"):
+        options.append("approved_memory_callback")
+
+    fresh = [c for c in options if c != pet.last_autopost_category]
+    return random.choice(fresh or options)
+
+
+def render(session, pet, category):
+    if category == "active_event_reminder":
+        event = events.active(session)
+        if event is not None:
+            return templates.AUTOPOST_EVENT_REMINDER.format(
+                name=pet.name, title=event.title, options=events.options_text(event)
+            )
+    if category == "approved_memory_callback":
+        suggestion = memory.random_approved(session)
+        if suggestion is not None:
+            return templates.AUTOPOST_MEMORY_CALLBACK.format(name=pet.name, memory=suggestion.content)
+
+    pool = responses.AUTOPOST.get(category) or responses.AUTOPOST["weird_observation"]
+    return responses._render(pool, pet)
+
+
+def compose(session, pet, config):
+    category = choose_category(session, pet, config)
+    text = render(session, pet, category)
+    pet.last_autopost_category = category
+    pet.last_autopost_at = utcnow()
+    return text
@@ -0,0 +1,317 @@+import argparse
+import sys
+
+from . import decay
+from . import engine
+from . import repository as repo
+from . import stats
+from .config import config
+from .db import create_all, init_engine, session_scope, wait_for_db
+from .models import utcnow
+from .parser import parse
+
+DEFAULT_USER = "local@cli"
+ACTION_COMMANDS = ["status", "feed", "play", "pet", "clean", "sleep", "wake", "help"]
+
+
+def _open():
+    init_engine(config.database_url)
+    wait_for_db()
+    create_all()
+    with session_scope() as session:
+        repo.get_or_create_pet(session, config)
+
+
+def run_command(command, user_acct, arg=""):
+    user_acct = (user_acct or DEFAULT_USER).lstrip("@")
+    with session_scope() as session:
+        pet = repo.get_pet(session)
+        user = repo.get_or_create_user(session, user_acct)
+        result = engine.handle(session, pet, user, command, config, arg=arg)
+    if result is None:
+        return {"text": f"({user_acct} is blocked or Prami is paused — nothing happens.)"}
+    return {
+        "text": result.text,
+        "favourite": result.favourite,
+        "boost": result.boost,
+        "milestones": [m["text"] for m in result.milestones],
+    }
+
+
+def _format_result(result):
+    line = result["text"]
+    notes = []
+    if result.get("boost"):
+        notes.append("boost-worthy")
+    elif result.get("favourite"):
+        notes.append("favourite-worthy")
+    if notes:
+        line += f"\n  · ({', '.join(notes)})"
+    for text in result.get("milestones", []):
+        line += f"\n  * milestone: {text}"
+    return line
+
+
+def render_relationship(user):
+    if user is None:
+        return "No relationship on record for that user yet."
+    rows = [
+        ("acct", user.acct),
+        ("nickname", user.nickname or "—"),
+        ("bond_level", user.bond_level),
+        ("trust_score", user.trust_score),
+        ("total_interactions", user.total_interactions),
+        ("feed/play/pet", f"{user.feed_count}/{user.play_count}/{user.pet_count}"),
+        ("clean/sleep/wake", f"{user.clean_count}/{user.sleep_count}/{user.wake_count}"),
+        ("first_seen", f"{user.first_seen:%Y-%m-%d %H:%M} UTC"),
+        ("last_seen", f"{user.last_seen:%Y-%m-%d %H:%M} UTC"),
+    ]
+    width = max(len(label) for label, _ in rows)
+    return "\n".join(f"{label.ljust(width)} : {value}" for label, value in rows)
+
+
+def simulate_ticks(minutes):
+    ticks = 1 if minutes <= 0 else max(1, minutes // config.decay_interval)
+    with session_scope() as session:
+        pet = repo.get_pet(session)
+        for _ in range(ticks):
+            decay.tick(pet)
+        mood = pet.mood
+    return ticks, mood
+
+
+def render_debug(pet):
+    if pet is None:
+        return "No pet found."
+    rows = [
+        ("name", pet.name),
+        ("hunger", pet.hunger),
+        ("happiness", pet.happiness),
+        ("energy", pet.energy),
+        ("health", pet.health),
+        ("cleanliness", pet.cleanliness),
+        ("social", pet.social),
+        ("trust", pet.trust),
+        ("chaos", pet.chaos),
+        ("asleep", pet.asleep),
+        ("mood", pet.mood),
+        ("age_days", pet.age_days),
+        ("updated_at", f"{pet.updated_at:%Y-%m-%d %H:%M:%S} UTC"),
+    ]
+    width = max(len(label) for label, _ in rows)
+    return "\n".join(f"{label.ljust(width)} : {value}" for label, value in rows)
+
+
+def cmd_action(args):
+    print(_format_result(run_command(args.command, args.user)))
+
+
+def cmd_nickname(args):
+    print(run_command("nickname", args.user, " ".join(args.name))["text"])
+
+
+def cmd_relationship(args):
+    acct = (args.user or DEFAULT_USER).lstrip("@")
+    with session_scope() as session:
+        print(render_relationship(repo.get_user(session, acct)))
+
+
+def cmd_teach(args):
+    print(run_command("teach", args.user, " ".join(args.content))["text"])
+
+
+def cmd_event(args):
+    print(run_command("event", args.user)["text"])
+
+
+def cmd_vote(args):
+    print(_format_result(run_command("vote", args.user, args.option)))
+
+
+def cmd_event_start(args):
+    from . import events
+
+    with session_scope() as session:
+        pet = repo.get_pet(session)
+        template = next((t for t in events.TEMPLATES if t["key"] == args.key), None) if args.key else None
+        event = events.start(session, pet, config, template=template)
+        print(f"Started event: {event.title} ({events.options_text(event)})")
+
+
+def cmd_event_complete(args):
+    from . import events, milestones
+
+    with session_scope() as session:
+        pet = repo.get_pet(session)
+        event = events.active(session)
+        if event is None:
+            print("No active event.")
+            return
+        outcome = events.complete(session, event, pet)
+        milestones.award_event_completed(session, pet)
+        print(f"Outcome: {outcome}")
+
+
+def cmd_admin(args):
+    acct = (args.user or DEFAULT_USER).lstrip("@")
+    if acct.lower() not in [a.lower() for a in config.admin_accounts]:
+        config.admin_accounts.append(acct)  # local CLI trusts the operator
+    print(run_command("admin", acct, " ".join(args.args))["text"])
+
+
+def cmd_debug(args):
+    with session_scope() as session:
+        print(render_debug(repo.get_pet(session)))
+
+
+def cmd_stats(args):
+    with session_scope() as session:
+        settings = repo.get_settings(session, config)
+        print(stats.text_report(session, config, settings, utcnow()))
+
+
+def cmd_tick(args):
+    minutes = args.hours * 60 + args.minutes
+    ticks, mood = simulate_ticks(minutes)
+    print(f"Simulated {ticks} decay tick(s). Prami is now {mood}.")
+
+
+def cmd_reset(args):
+    if not args.yes:
+        answer = input("Reset local pet state to defaults? [y/N] ").strip().lower()
+        if answer not in ("y", "yes"):
+            print("Cancelled.")
+            return
+    with session_scope() as session:
+        repo.reset_world(session, config)
+    print("Prami has been reset to a fresh, slightly confused default state.")
+
+
+def cmd_shell(args):
+    print("Prami local shell — try '@alice feed', 'status', 'tick 2h', 'debug', or 'exit'.")
+    while True:
+        try:
+            line = input("prami> ").strip()
+        except (EOFError, KeyboardInterrupt):
+            print()
+            break
+        if not line:
+            continue
+
+        low = line.lower()
+        if low in ("exit", "quit"):
+            break
+        if low == "debug":
+            with session_scope() as session:
+                print(render_debug(repo.get_pet(session)))
+            continue
+        if low == "tick" or low.startswith("tick "):
+            ticks, mood = simulate_ticks(_shell_minutes(line))
+            print(f"(simulated {ticks} tick(s); mood: {mood})")
+            continue
+        if low.startswith("whois"):
+            parts = line.split(None, 1)
+            acct = (parts[1].lstrip("@") if len(parts) > 1 else DEFAULT_USER)
+            with session_scope() as session:
+                print(render_relationship(repo.get_user(session, acct)))
+            continue
+
+        if line.startswith("@"):
+            head, _, rest = line.partition(" ")
+            user_acct = head[1:]
+            parsed = parse(rest)
+        else:
+            user_acct = DEFAULT_USER
+            parsed = parse(line)
+
+        print(f"Prami → {_format_result(run_command(parsed.name, user_acct, parsed.arg))}")
+
+
+def _shell_minutes(line):
+    parts = line.split()
+    if len(parts) < 2:
+        return 0
+    token = parts[1].lower()
+    try:
+        if token.endswith("h"):
+            return int(token[:-1]) * 60
+        if token.endswith("m"):
+            return int(token[:-1])
+        return int(token)
+    except ValueError:
+        return 0
+
+
+def build_parser():
+    parser = argparse.ArgumentParser(prog="prami.cli", description="Local testing CLI for Prami")
+    sub = parser.add_subparsers(dest="cmd", required=True)
+
+    for name in ACTION_COMMANDS:
+        action = sub.add_parser(name, help=f"Run the '{name}' command as a user")
+        action.add_argument("--user", default=None, help="Acting user, e.g. @alice@example.com")
+        action.set_defaults(func=cmd_action, command=name)
+
+    nickname = sub.add_parser("nickname", help="Set or clear your nickname (use 'clear' to remove)")
+    nickname.add_argument("name", nargs="*", help="Nickname text, or 'clear'")
+    nickname.add_argument("--user", default=None, help="Acting user, e.g. @alice@example.com")
+    nickname.set_defaults(func=cmd_nickname)
+
+    relationship = sub.add_parser("relationship", help="Inspect a user's relationship data (dev only)")
+    relationship.add_argument("--user", default=None, help="User to inspect")
+    relationship.set_defaults(func=cmd_relationship)
+
+    teach = sub.add_parser("teach", help="Suggest a memory (needs ENABLE_TEACH_COMMAND=true)")
+    teach.add_argument("content", nargs="*", help="The lore/fact/preference to suggest")
+    teach.add_argument("--user", default=None, help="Acting user")
+    teach.set_defaults(func=cmd_teach)
+
+    admin_cmd = sub.add_parser("admin", help="Run an admin command (CLI grants local admin)")
+    admin_cmd.add_argument("args", nargs="*", help="e.g. status / pause / memories / approve-memory 1")
+    admin_cmd.add_argument("--user", default=None, help="Acting user")
+    admin_cmd.set_defaults(func=cmd_admin)
+
+    event = sub.add_parser("event", help="Show the active community event")
+    event.add_argument("--user", default=None)
+    event.set_defaults(func=cmd_event)
+
+    vote = sub.add_parser("vote", help="Vote on the active event")
+    vote.add_argument("option", help="The option key, e.g. soup")
+    vote.add_argument("--user", default=None)
+    vote.set_defaults(func=cmd_vote)
+
+    event_start = sub.add_parser("event-start", help="Force-start an event (dev only)")
+    event_start.add_argument("key", nargs="?", default=None, help="Optional template key")
+    event_start.set_defaults(func=cmd_event_start)
+
+    sub.add_parser("event-complete", help="Force-complete the active event (dev only)").set_defaults(
+        func=cmd_event_complete
+    )
+
+    sub.add_parser("shell", help="Interactive local shell").set_defaults(func=cmd_shell)
+    sub.add_parser("debug-state", help="Print raw internal state (dev only)").set_defaults(func=cmd_debug)
+    sub.add_parser("stats", help="Show local operational metrics (admin/dev only)").set_defaults(func=cmd_stats)
+
+    tick = sub.add_parser("tick", help="Simulate time passing (state decay)")
+    tick.add_argument("--hours", type=int, default=0)
+    tick.add_argument("--minutes", type=int, default=0)
+    tick.set_defaults(func=cmd_tick)
+
+    reset = sub.add_parser("reset", help="Reset local pet state to defaults")
+    reset.add_argument("--yes", action="store_true", help="Skip the confirmation prompt")
+    reset.set_defaults(func=cmd_reset)
+
+    return parser
+
+
+def main(argv=None):
+    try:
+        sys.stdout.reconfigure(encoding="utf-8")
+    except (AttributeError, ValueError):
+        pass
+    args = build_parser().parse_args(argv)
+    _open()
+    args.func(args)
+
+
+if __name__ == "__main__":
+    main()
@@ -0,0 +1,93 @@+import os
+from dataclasses import dataclass, field
+
+from dotenv import load_dotenv
+
+load_dotenv()
+
+
+def _int(name, default):
+    raw = os.getenv(name)
+    if raw is None or raw.strip() == "":
+        return default
+    return int(raw)
+
+
+def _bool(name, default):
+    raw = os.getenv(name)
+    if raw is None:
+        return default
+    return raw.strip().lower() in ("1", "true", "yes", "on")
+
+
+def _float(name, default):
+    raw = os.getenv(name)
+    if raw is None or raw.strip() == "":
+        return default
+    return float(raw)
+
+
+def _csv(name):
+    raw = os.getenv(name, "")
+    return [item.strip().lstrip("@") for item in raw.split(",") if item.strip()]
+
+
+@dataclass
+class Config:
+    mastodon_base_url: str = os.getenv("MASTODON_BASE_URL", "")
+    mastodon_access_token: str = os.getenv("MASTODON_ACCESS_TOKEN", "")
+    bot_acct: str = os.getenv("BOT_ACCOUNT_ACCT", "").lstrip("@")
+    database_url: str = os.getenv("DATABASE_URL", "sqlite:///prami.db")
+
+    poll_interval: int = _int("POLL_INTERVAL_SECONDS", 30)
+    decay_interval: int = _int("STATE_DECAY_INTERVAL_MINUTES", 15)
+    autopost_interval: int = _int("AUTOPOST_INTERVAL_MINUTES", 240)
+
+    default_visibility: str = os.getenv("DEFAULT_VISIBILITY", "unlisted")
+    autopost_visibility: str = os.getenv("AUTOPOST_VISIBILITY", "unlisted")
+    autopost_enabled: bool = _bool("AUTOPOST_ENABLED", True)
+
+    pet_name: str = os.getenv("PET_NAME", "Prami")
+    pet_species: str = os.getenv("PET_SPECIES", "community critter")
+    pet_personality: str = os.getenv("PET_PERSONALITY", "weird, affectionate, slightly dramatic")
+    personality_pack: str = os.getenv("PET_PERSONALITY_PACK", "default")
+
+    max_per_user_hour: int = _int("MAX_COMMANDS_PER_USER_PER_HOUR", 20)
+    max_global_hour: int = _int("MAX_COMMANDS_GLOBAL_PER_HOUR", 200)
+    blocked_users: list = field(default_factory=lambda: _csv("BLOCKED_USERS"))
+
+    processed_retention_days: int = _int("PROCESSED_RETENTION_DAYS", 14)
+
+    enable_favourites: bool = _bool("ENABLE_FAVOURITES", False)
+    max_favourites_per_hour: int = _int("MAX_FAVOURITES_PER_HOUR", 8)
+    max_favourites_per_user_per_day: int = _int("MAX_FAVOURITES_PER_USER_PER_DAY", 3)
+    allow_favourite_private: bool = _bool("ALLOW_FAVOURITE_PRIVATE", False)
+
+    enable_boosts: bool = _bool("ENABLE_BOOSTS", False)
+    max_boosts_per_day: int = _int("MAX_BOOSTS_PER_DAY", 1)
+    min_hours_between_boosts: int = _int("MIN_HOURS_BETWEEN_BOOSTS", 12)
+    boost_only_public_statuses: bool = _bool("BOOST_ONLY_PUBLIC_STATUSES", True)
+
+    enable_teach_command: bool = _bool("ENABLE_TEACH_COMMAND", False)
+    admin_accounts: list = field(default_factory=lambda: _csv("ADMIN_ACCOUNTS"))
+    admin_refuse_nonadmin: bool = _bool("ADMIN_REFUSE_NONADMIN", False)
+    memory_allow_urls: bool = _bool("MEMORY_ALLOW_URLS", False)
+    memory_max_length: int = _int("MEMORY_MAX_LENGTH", 180)
+
+    enable_events: bool = _bool("ENABLE_EVENTS", False)
+    event_chance_per_autopost: float = _float("EVENT_CHANCE_PER_AUTOPOST", 0.15)
+    max_active_events: int = _int("MAX_ACTIVE_EVENTS", 1)
+    event_duration_hours: int = _int("EVENT_DURATION_HOURS", 12)
+    event_cooldown_hours: int = _int("EVENT_COOLDOWN_HOURS", 24)
+
+    enable_milestone_posts: bool = _bool("ENABLE_MILESTONE_POSTS", True)
+    milestone_post_visibility: str = os.getenv("MILESTONE_POST_VISIBILITY", "unlisted")
+
+    log_level: str = os.getenv("LOG_LEVEL", "INFO")
+
+    def is_admin(self, acct):
+        target = (acct or "").lstrip("@").lower()
+        return any(target == a.lower() for a in self.admin_accounts)
+
+
+config = Config()
@@ -0,0 +1,64 @@+import logging
+import time
+from contextlib import contextmanager
+
+from sqlalchemy import create_engine, text
+from sqlalchemy.orm import DeclarativeBase, sessionmaker
+
+log = logging.getLogger("prami.db")
+
+
+class Base(DeclarativeBase):
+    pass
+
+
+_engine = None
+_Session = None
+
+
+def init_engine(database_url):
+    global _engine, _Session
+
+    connect_args = {}
+    if database_url.startswith("sqlite"):
+        connect_args["check_same_thread"] = False
+
+    _engine = create_engine(
+        database_url,
+        future=True,
+        pool_pre_ping=True,
+        connect_args=connect_args,
+    )
+    _Session = sessionmaker(bind=_engine, expire_on_commit=False, future=True)
+    return _engine
+
+
+def create_all():
+    from . import models  # noqa: F401  (registers the mappers)
+
+    Base.metadata.create_all(_engine)
+
+
+def wait_for_db(retries=10, delay=3):
+    for attempt in range(1, retries + 1):
+        try:
+            with _engine.connect() as conn:
+                conn.execute(text("SELECT 1"))
+            return
+        except Exception as exc:
+            log.warning("Database not ready (%s/%s): %s", attempt, retries, exc)
+            time.sleep(delay)
+    raise RuntimeError("Database did not become available")
+
+
+@contextmanager
+def session_scope():
+    session = _Session()
+    try:
+        yield session
+        session.commit()
+    except Exception:
+        session.rollback()
+        raise
+    finally:
+        session.close()
@@ -0,0 +1,34 @@+import random
+
+from .models import utcnow
+from .state import clamp, compute_mood
+
+
+def tick(pet):
+    if pet.asleep:
+        pet.energy = clamp(pet.energy + 12)
+        pet.hunger = clamp(pet.hunger + 3)
+    else:
+        pet.energy = clamp(pet.energy - 6)
+        pet.hunger = clamp(pet.hunger + 7)
+
+    pet.cleanliness = clamp(pet.cleanliness - 5)
+    pet.social = clamp(pet.social - 6)
+
+    strain = 0
+    if pet.hunger > 75:
+        strain += 3
+    if pet.cleanliness < 25:
+        strain += 2
+    if pet.energy < 15:
+        strain += 2
+
+    if strain:
+        pet.health = clamp(pet.health - strain)
+    elif pet.hunger < 50 and pet.cleanliness > 50 and pet.energy > 40:
+        pet.health = clamp(pet.health + 1)
+
+    pet.chaos = clamp(pet.chaos + random.randint(-4, 5))
+    pet.mood = compute_mood(pet)
+    pet.last_decay_at = utcnow()
+    return pet
@@ -0,0 +1,90 @@+from datetime import timedelta
+
+from . import milestones
+from . import relationships
+from . import repository as repo
+from . import responses
+from .actions.base import ActionContext
+from .actions.registry import REGISTRY
+from .models import utcnow
+from .result import Result
+
+
+def handle(session, pet, user, command, config, status_id=None, now=None, arg=""):
+    now = now or utcnow()
+    settings = repo.get_settings(session, config)
+    action = REGISTRY.resolve(command or "unknown")
+    ctx = ActionContext(session, pet, user, arg or "", config, settings, now, status_id)
+
+    if action.admin_only:
+        return _finalize(ctx, action, action.run(ctx))
+
+    if user.blocked or settings.paused:
+        return None
+
+    if repo.count_global_interactions_last_hour(session, now) >= config.max_global_hour:
+        repo.log_event(session, pet.id, "rate_block_global", f"{user.acct} hit the global cap")
+        return None
+    if repo.count_user_interactions_last_hour(session, user.id, now) >= config.max_per_user_hour:
+        return Result(responses.too_much(pet), settings.default_visibility, False)
+
+    if not action.enabled(config):
+        return _finalize(ctx, action, action.on_disabled(ctx))
+
+    if pet.asleep and not action.runs_while_asleep:
+        return Result(responses.too_sleepy(action.name, pet), settings.default_visibility, False)
+
+    early = action.precheck(ctx)
+    if early is not None:
+        return _finalize(ctx, action, early)
+
+    reason = _cooldown_reason(session, pet, user, action, now)
+    if reason:
+        return Result(responses.cooldown(action.name, reason, pet), settings.default_visibility, False)
+
+    return _finalize(ctx, action, action.run(ctx))
+
+
+def _finalize(ctx, action, outcome):
+    if outcome is None:
+        return None
+
+    session, pet, user = ctx.session, ctx.pet, ctx.user
+    achieved = []
+
+    if outcome.accepted:
+        _touch_cooldowns(session, pet, user, action, ctx.now)
+        if outcome.record:
+            repo.record_interaction(session, pet.id, user.id, action.name, ctx.status_id, True)
+            relationships.record(user, action.name, pet, outcome.was_critical)
+            user.last_seen = utcnow()
+            achieved = milestones.check(session, pet, user, action.name, outcome.was_critical)
+
+    text = responses.maybe_address(outcome.text, user) if outcome.address else outcome.text
+    visibility = outcome.visibility or ctx.settings.default_visibility
+    return Result(text, visibility, outcome.accepted, outcome.favourite, outcome.boost, achieved)
+
+
+def _cooldown_reason(session, pet, user, action, now):
+    if action.global_cooldown_minutes:
+        cd = repo.get_cooldown(session, pet.id, None, action.name)
+        if cd and now - cd.last_used_at < timedelta(minutes=action.global_cooldown_minutes):
+            return "global"
+
+    if action.user_cooldown_minutes:
+        cd = repo.get_cooldown(session, pet.id, user.id, action.name)
+        if cd and now - cd.last_used_at < timedelta(minutes=action.user_cooldown_minutes):
+            return "user"
+
+    if action.per_hour_limit is not None:
+        if repo.count_command_last_hour(session, action.name, now) >= action.per_hour_limit:
+            return "busy"
+
+    return None
+
+
+def _touch_cooldowns(session, pet, user, action, now):
+    if action.user_cooldown_minutes:
+        repo.touch_cooldown(session, pet.id, user.id, action.name, now)
+    if action.global_cooldown_minutes:
+        repo.touch_cooldown(session, pet.id, None, action.name, now)
@@ -0,0 +1,106 @@+import json
+import random
+from datetime import timedelta
+
+from sqlalchemy import func, select
+
+from .models import CommunityEvent, EventVote, utcnow
+from .state import clamp
+from .templates import EVENT_SCENARIOS as TEMPLATES
+
+
+def active(session, now=None):
+    return session.scalars(
+        select(CommunityEvent).where(CommunityEvent.status == "active").order_by(CommunityEvent.id)
+    ).first()
+
+
+def options_text(event):
+    return ", ".join(f"{key} ({opt['label']})" for key, opt in json.loads(event.options).items())
+
+
+def _active_count(session):
+    return session.scalar(
+        select(func.count()).select_from(CommunityEvent).where(CommunityEvent.status == "active")
+    ) or 0
+
+
+def _last_event_end(session):
+    return session.scalar(
+        select(func.max(CommunityEvent.ends_at)).where(CommunityEvent.status.in_(("completed", "expired")))
+    )
+
+
+def start(session, pet, config, now=None, template=None):
+    now = now or utcnow()
+    tmpl = template or random.choice(TEMPLATES)
+    event = CommunityEvent(
+        pet_id=pet.id,
+        key=tmpl["key"],
+        title=tmpl["title"],
+        description=tmpl["description"],
+        options=json.dumps(tmpl["options"]),
+        status="active",
+        starts_at=now,
+        ends_at=now + timedelta(hours=config.event_duration_hours),
+    )
+    session.add(event)
+    session.flush()
+    return event
+
+
+def maybe_start(session, pet, config, now=None, force=False):
+    now = now or utcnow()
+    if active(session, now) or _active_count(session) >= config.max_active_events:
+        return None
+    if not force:
+        last_end = _last_event_end(session)
+        if last_end and now - last_end < timedelta(hours=config.event_cooldown_hours):
+            return None
+        if random.random() >= config.event_chance_per_autopost:
+            return None
+    return start(session, pet, config, now)
+
+
+def vote(session, event, user, option):
+    options = json.loads(event.options)
+    option = (option or "").strip().lower()
+    if option not in options:
+        return False, "unknown option"
+    existing = session.scalars(
+        select(EventVote).where(EventVote.event_id == event.id, EventVote.user_id == user.id)
+    ).first()
+    if existing:
+        return False, "already voted"
+    session.add(EventVote(event_id=event.id, user_id=user.id, option=option))
+    session.flush()
+    return True, "ok"
+
+
+def complete(session, event, pet, now=None):
+    options = json.loads(event.options)
+    tally = {}
+    for v in session.scalars(select(EventVote).where(EventVote.event_id == event.id)):
+        tally[v.option] = tally.get(v.option, 0) + 1
+
+    order = list(options)
+    if tally:
+        winner = max(tally, key=lambda key: (tally[key], -order.index(key)))
+    else:
+        winner = order[0]
+
+    outcome = options[winner]
+    for stat, delta in outcome.get("deltas", {}).items():
+        setattr(pet, stat, clamp(getattr(pet, stat) + delta))
+
+    event.status = "completed"
+    event.outcome = outcome["text"]
+    return outcome["text"]
+
+
+def expire_due(session, pet, now=None):
+    now = now or utcnow()
+    due = session.scalars(
+        select(CommunityEvent).where(CommunityEvent.status == "active", CommunityEvent.ends_at <= now)
+    )
+    return [complete(session, event, pet, now) for event in list(due)]
@@ -0,0 +1,32 @@+import logging
+
+from mastodon import Mastodon
+
+log = logging.getLogger("prami.mastodon")
+
+
+class PramiClient:
+    def __init__(self, config):
+        self.config = config
+        self.api = Mastodon(
+            access_token=config.mastodon_access_token,
+            api_base_url=config.mastodon_base_url,
+            ratelimit_method="pace",
+        )
+        self.me = self.api.account_verify_credentials()
+        log.info("Authenticated as @%s (id=%s)", self.me.acct, self.me.id)
+
+    def fetch_mentions(self, since_id=None):
+        return self.api.notifications(types=["mention"], since_id=since_id, limit=30)
+
+    def reply(self, status, text, visibility):
+        return self.api.status_post(text, in_reply_to_id=status.id, visibility=visibility)
+
+    def post(self, text, visibility):
+        return self.api.status_post(text, visibility=visibility)
+
+    def favourite(self, status):
+        return self.api.status_favourite(status.id)
+
+    def boost(self, status):
+        return self.api.status_reblog(status.id)
@@ -0,0 +1,71 @@+import random
+
+from sqlalchemy import select
+
+from . import sanitize
+from .models import MemorySuggestion, utcnow
+
+CATEGORIES = ("phrase", "preference", "fear", "lore", "observation")
+
+
+def categorize(content):
+    low = content.lower()
+    if any(word in low for word in ("afraid", "fear", "scared", "terrified")):
+        return "fear"
+    if any(word in low for word in ("likes", "prefers", "loves", "hates", "favourite", "favorite")):
+        return "preference"
+    if content.strip()[:1] in "\"'" or "says" in low:
+        return "phrase"
+    if any(word in low for word in ("believes", "thinks", "knows")):
+        return "lore"
+    return "observation"
+
+
+def submit(session, pet, user, raw, config):
+    content = sanitize.clean_memory(raw, config.memory_max_length, config.memory_allow_urls)
+    if not content:
+        return None
+    suggestion = MemorySuggestion(
+        pet_id=pet.id,
+        submitted_by_user_id=user.id,
+        content=content,
+        category=categorize(content),
+    )
+    session.add(suggestion)
+    session.flush()
+    return suggestion
+
+
+def by_status(session, status):
+    stmt = select(MemorySuggestion).where(MemorySuggestion.status == status).order_by(MemorySuggestion.id)
+    return list(session.scalars(stmt))
+
+
+def get(session, suggestion_id):
+    return session.get(MemorySuggestion, suggestion_id)
+
+
+def approve(session, suggestion_id, reviewer):
+    suggestion = get(session, suggestion_id)
+    if suggestion is None or suggestion.status != "pending":
+        return None
+    suggestion.status = "approved"
+    suggestion.reviewed_at = utcnow()
+    suggestion.reviewed_by = reviewer
+    return suggestion
+
+
+def reject(session, suggestion_id, reviewer, reason=""):
+    suggestion = get(session, suggestion_id)
+    if suggestion is None or suggestion.status != "pending":
+        return None
+    suggestion.status = "rejected"
+    suggestion.reviewed_at = utcnow()
+    suggestion.reviewed_by = reviewer
+    suggestion.rejection_reason = (reason or "").strip()[:180] or None
+    return suggestion
+
+
+def random_approved(session):
+    approved = by_status(session, "approved")
+    return random.choice(approved) if approved else None
@@ -0,0 +1,63 @@+from sqlalchemy import select
+
+from . import repository as repo
+from .models import Milestone
+from .relationships import CARE_COMMANDS
+
+
+def _already(session, pet_id, user_id, key):
+    stmt = select(Milestone).where(Milestone.pet_id == pet_id, Milestone.key == key)
+    stmt = stmt.where(Milestone.user_id.is_(None) if user_id is None else Milestone.user_id == user_id)
+    return session.scalars(stmt).first() is not None
+
+
+def check(session, pet, user, command, was_critical):
+    found = []
+
+    def award(key, scope, user_id, text, favourite=False, boost=False):
+        if _already(session, pet.id, user_id, key):
+            return
+        session.add(Milestone(pet_id=pet.id, user_id=user_id, key=key, scope=scope))
+        session.flush()
+        found.append({"key": key, "scope": scope, "text": text, "favourite": favourite, "boost": boost})
+
+    name = pet.name
+
+    if user.total_interactions == 1:
+        award("first_interaction", "user", user.id, f"A new face met {name} today. Welcome, @{user.acct}.")
+    if user.total_interactions == 10:
+        award("user_10", "user", user.id, f"@{user.acct} has looked after {name} ten times now. {name} noticed.")
+    if user.total_interactions == 25:
+        award("user_25", "user", user.id, f"@{user.acct} hit 25 interactions with {name}. That's basically family.")
+    if was_critical and command in CARE_COMMANDS:
+        award("first_critical_save", "user", user.id,
+              f"@{user.acct} helped {name} in a rough moment. {name} will remember this.", favourite=True)
+    if user.bond_level == "trusted":
+        award("reached_trusted", "user", user.id, f"@{user.acct} and {name} reached 'trusted'. Hard-won and real.")
+    if user.bond_level == "friend":
+        award("reached_friend", "user", user.id, f"@{user.acct} is officially {name}'s friend now. Big deal, quietly.")
+
+    if command == "feed" and repo.count_interactions_total(session, "feed") == 1:
+        award("first_feeding", "community", None, f"{name} was fed for the very first time. A historic snack.", favourite=True)
+    if command == "clean" and repo.count_interactions_total(session, "clean") == 1:
+        award("first_bath_survived", "community", None, f"{name} survived its first bath. Furious, clean, alive.")
+    if repo.count_interactions_total(session) >= 100:
+        award("100_interactions", "community", None, f"The community has cared for {name} 100 times. Thank you, all of you.", favourite=True, boost=True)
+    if pet.age_days >= 7:
+        award("7_days_alive", "community", None, f"{name} has been alive for a week. Seven whole days of tiny chaos.")
+    if was_critical and command == "feed":
+        award("saved_from_critical_hunger", "community", None, f"{name} was saved from going truly hungry. Good work, everyone.", favourite=True)
+    if pet.chaos >= 100:
+        award("chaos_100", "community", None, f"{name}'s chaos hit maximum and nobody was harmed. Mostly.")
+
+    return found
+
+
+def award_event_completed(session, pet):
+    if _already(session, pet.id, None, "first_event_completed"):
+        return None
+    session.add(Milestone(pet_id=pet.id, user_id=None, key="first_event_completed", scope="community"))
+    session.flush()
+    return {"key": "first_event_completed", "scope": "community",
+            "text": f"{pet.name} completed its first community event. Democracy survived.",
+            "favourite": False, "boost": False}
@@ -0,0 +1,189 @@+from datetime import datetime, timezone
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from .db import Base
+
+
+def utcnow():
+    return datetime.now(timezone.utc).replace(tzinfo=None)
+
+
+class Pet(Base):
+    __tablename__ = "pets"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(80))
+    species: Mapped[str] = mapped_column(String(120))
+    personality: Mapped[str] = mapped_column(String(255))
+
+    hunger: Mapped[int] = mapped_column(default=40)
+    happiness: Mapped[int] = mapped_column(default=60)
+    energy: Mapped[int] = mapped_column(default=70)
+    health: Mapped[int] = mapped_column(default=90)
+    cleanliness: Mapped[int] = mapped_column(default=70)
+    social: Mapped[int] = mapped_column(default=50)
+    trust: Mapped[int] = mapped_column(default=30)
+    chaos: Mapped[int] = mapped_column(default=20)
+
+    asleep: Mapped[bool] = mapped_column(Boolean, default=False)
+    mood: Mapped[str] = mapped_column(String(40), default="content")
+
+    last_autopost_category: Mapped[str | None] = mapped_column(String(40), nullable=True)
+    last_autopost_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    last_decay_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+
+    born_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
+    created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
+    updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, onupdate=utcnow)
+
+    @property
+    def age_days(self):
+        return max(0, (utcnow() - self.born_at).days)
+
+
+class User(Base):
+    __tablename__ = "users"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    acct: Mapped[str] = mapped_column(String(255), unique=True, index=True)
+    mastodon_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
+    display_name: Mapped[str] = mapped_column(String(255), default="")
+    nickname: Mapped[str | None] = mapped_column(String(32), nullable=True)
+    blocked: Mapped[bool] = mapped_column(Boolean, default=False)
+
+    total_interactions: Mapped[int] = mapped_column(default=0)
+    feed_count: Mapped[int] = mapped_column(default=0)
+    play_count: Mapped[int] = mapped_column(default=0)
+    pet_count: Mapped[int] = mapped_column(default=0)
+    clean_count: Mapped[int] = mapped_column(default=0)
+    sleep_count: Mapped[int] = mapped_column(default=0)
+    wake_count: Mapped[int] = mapped_column(default=0)
+
+    trust_score: Mapped[int] = mapped_column(default=0)
+    bond_level: Mapped[str] = mapped_column(String(20), default="stranger")
+    last_milestone_seen: Mapped[str | None] = mapped_column(String(60), nullable=True)
+
+    first_seen: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
+    last_seen: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
+
+
+class Interaction(Base):
+    __tablename__ = "interactions"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    pet_id: Mapped[int] = mapped_column(ForeignKey("pets.id"), index=True)
+    user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
+    command: Mapped[str] = mapped_column(String(40), index=True)
+    status_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
+    accepted: Mapped[bool] = mapped_column(Boolean, default=True)
+    created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, index=True)
+
+    user: Mapped[User] = relationship()
+
+
+class Cooldown(Base):
+    __tablename__ = "cooldowns"
+    __table_args__ = (UniqueConstraint("pet_id", "user_id", "command", name="uq_cooldown_scope"),)
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    pet_id: Mapped[int] = mapped_column(ForeignKey("pets.id"), index=True)
+    user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
+    command: Mapped[str] = mapped_column(String(40))
+    last_used_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
+
+
+class ProcessedStatus(Base):
+    __tablename__ = "processed_statuses"
+
+    status_id: Mapped[str] = mapped_column(String(64), primary_key=True)
+    created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
+
+
+class Event(Base):
+    __tablename__ = "events"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    pet_id: Mapped[int | None] = mapped_column(ForeignKey("pets.id"), nullable=True)
+    kind: Mapped[str] = mapped_column(String(60), index=True)
+    detail: Mapped[str] = mapped_column(Text, default="")
+    created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, index=True)
+
+
+class Settings(Base):
+    __tablename__ = "settings"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    paused: Mapped[bool] = mapped_column(Boolean, default=False)
+    autopost_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
+    favourites_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
+    boosts_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
+    default_visibility: Mapped[str] = mapped_column(String(20), default="unlisted")
+    autopost_visibility: Mapped[str] = mapped_column(String(20), default="unlisted")
+
+
+class SocialAction(Base):
+    __tablename__ = "social_actions"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    pet_id: Mapped[int] = mapped_column(ForeignKey("pets.id"), index=True)
+    user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
+    status_id: Mapped[str] = mapped_column(String(64), index=True)
+    action: Mapped[str] = mapped_column(String(20), index=True)
+    reason: Mapped[str] = mapped_column(String(120), default="")
+    created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, index=True)
+
+
+class MemorySuggestion(Base):
+    __tablename__ = "memory_suggestions"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    pet_id: Mapped[int] = mapped_column(ForeignKey("pets.id"), index=True)
+    submitted_by_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
+    content: Mapped[str] = mapped_column(String(255))
+    category: Mapped[str] = mapped_column(String(20), default="lore")
+    status: Mapped[str] = mapped_column(String(12), default="pending", index=True)
+    created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
+    reviewed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    reviewed_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
+    rejection_reason: Mapped[str | None] = mapped_column(String(180), nullable=True)
+
+
+class CommunityEvent(Base):
+    __tablename__ = "community_events"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    pet_id: Mapped[int] = mapped_column(ForeignKey("pets.id"), index=True)
+    key: Mapped[str] = mapped_column(String(60))
+    title: Mapped[str] = mapped_column(String(120))
+    description: Mapped[str] = mapped_column(String(255))
+    options: Mapped[str] = mapped_column(Text, default="{}")
+    status: Mapped[str] = mapped_column(String(12), default="active", index=True)
+    outcome: Mapped[str | None] = mapped_column(String(255), nullable=True)
+    starts_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
+    ends_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
+    created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
+
+
+class EventVote(Base):
+    __tablename__ = "event_votes"
+    __table_args__ = (UniqueConstraint("event_id", "user_id", name="uq_event_vote"),)
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    event_id: Mapped[int] = mapped_column(ForeignKey("community_events.id"), index=True)
+    user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
+    option: Mapped[str] = mapped_column(String(40))
+    created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
+
+
+class Milestone(Base):
+    __tablename__ = "milestones"
+    __table_args__ = (UniqueConstraint("pet_id", "user_id", "key", name="uq_milestone"),)
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    pet_id: Mapped[int] = mapped_column(ForeignKey("pets.id"), index=True)
+    user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
+    key: Mapped[str] = mapped_column(String(60))
+    scope: Mapped[str] = mapped_column(String(12), default="community")
+    created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
@@ -0,0 +1,51 @@+import re
+from dataclasses import dataclass
+from html import unescape
+
+from .actions.registry import REGISTRY
+
+# Command vocabulary comes from the action registry, so parsing and execution
+# never drift apart.
+_ALIAS_TO_COMMAND = REGISTRY.alias_map()
+PHRASE_ALIASES = REGISTRY.phrase_map()
+
+_MENTION = re.compile(r"@[\w.\-]+(?:@[\w.\-]+)?")
+_WHITESPACE = re.compile(r"\s+")
+
+
+@dataclass
+class Parsed:
+    name: str | None
+    arg: str = ""
+
+
+def strip_html(content):
+    text = re.sub(r"(?i)<br\s*/?>", " ", content)
+    text = re.sub(r"(?i)</p>", " ", text)
+    text = re.sub(r"<[^>]+>", "", text)
+    return unescape(text)
+
+
+def _after(raw, keyword):
+    match = re.search(r"\b" + re.escape(keyword) + r"\b", raw, re.IGNORECASE)
+    return raw[match.end():].strip() if match else ""
+
+
+def parse(content):
+    raw = _WHITESPACE.sub(" ", strip_html(content)).strip()
+    detect = _WHITESPACE.sub(" ", _MENTION.sub(" ", raw)).strip().lower()
+
+    for phrase, command in PHRASE_ALIASES.items():
+        if detect.startswith(phrase):
+            return Parsed(command, _after(raw, phrase))
+
+    for token in detect.split():
+        command = _ALIAS_TO_COMMAND.get(token)
+        if command:
+            return Parsed(command, _after(raw, token))
+
+    return Parsed(None)
+
+
+def parse_command(content):
+    return parse(content).name
@@ -0,0 +1,53 @@+from dataclasses import dataclass
+from functools import lru_cache
+
+KNOWN_TRAITS = (
+    "weird",
+    "affectionate",
+    "dramatic",
+    "suspicious",
+    "chaotic",
+    "calm",
+    "shy",
+    "clingy",
+    "grumpy",
+    "brave",
+)
+
+
+@dataclass(frozen=True)
+class Personality:
+    traits: frozenset
+
+    def has(self, trait):
+        return trait in self.traits
+
+    @property
+    def chaos_threshold(self):
+        if "chaotic" in self.traits:
+            return 55
+        if "calm" in self.traits:
+            return 82
+        return 70
+
+    @property
+    def suspicion_threshold(self):
+        if "suspicious" in self.traits:
+            return 35
+        if "affectionate" in self.traits or "clingy" in self.traits:
+            return 18
+        return 25
+
+    @property
+    def tired_threshold(self):
+        return 30 if "dramatic" in self.traits else 22
+
+    @property
+    def happy_threshold(self):
+        return 62 if "affectionate" in self.traits else 70
+
+
+@lru_cache(maxsize=64)
+def from_string(text):
+    low = (text or "").lower()
+    return Personality(frozenset(t for t in KNOWN_TRAITS if t in low))
@@ -0,0 +1,41 @@+from .state import clamp
+
+BOND_LEVELS = ("stranger", "familiar", "trusted", "friend", "beloved menace")
+
+CARE_COMMANDS = ("feed", "play", "pet", "clean", "sleep", "wake")
+_COUNT_ATTR = {command: f"{command}_count" for command in CARE_COMMANDS}
+
+TRUST_GAIN = {"feed": 3, "play": 2, "pet": 4, "clean": 3, "sleep": 1, "wake": 1, "nickname": 1}
+CRITICAL_BONUS = 6
+
+
+def is_critical(pet):
+    return pet.hunger >= 85 or pet.health <= 20 or pet.cleanliness <= 15 or pet.energy <= 10
+
+
+def bond_for(trust_score):
+    if trust_score >= 80:
+        return "beloved menace"
+    if trust_score >= 55:
+        return "friend"
+    if trust_score >= 30:
+        return "trusted"
+    if trust_score >= 12:
+        return "familiar"
+    return "stranger"
+
+
+def record(user, command, pet, was_critical=False):
+    user.total_interactions += 1
+
+    attr = _COUNT_ATTR.get(command)
+    if attr:
+        setattr(user, attr, getattr(user, attr) + 1)
+
+    gain = TRUST_GAIN.get(command, 0)
+    if attr and was_critical:
+        gain += CRITICAL_BONUS
+    if gain:
+        user.trust_score = clamp(user.trust_score + gain, 0, 100)
+
+    user.bond_level = bond_for(user.trust_score)
@@ -0,0 +1,227 @@+from datetime import timedelta
+
+from sqlalchemy import delete, func, select
+
+from .models import (
+    Cooldown,
+    CommunityEvent,
+    Event,
+    EventVote,
+    Interaction,
+    MemorySuggestion,
+    Milestone,
+    Pet,
+    ProcessedStatus,
+    Settings,
+    SocialAction,
+    User,
+    utcnow,
+)
+
+
+def get_pet(session):
+    return session.scalars(select(Pet).order_by(Pet.id)).first()
+
+
+def get_or_create_pet(session, config):
+    pet = get_pet(session)
+    if pet is None:
+        pet = Pet(
+            name=config.pet_name,
+            species=config.pet_species,
+            personality=config.pet_personality,
+        )
+        session.add(pet)
+        session.flush()
+    return pet
+
+
+def get_user(session, acct):
+    return session.scalars(select(User).where(User.acct == acct.lstrip("@"))).first()
+
+
+def get_or_create_user(session, acct, mastodon_id=None, display_name=""):
+    acct = acct.lstrip("@")
+    user = session.scalars(select(User).where(User.acct == acct)).first()
+    if user is None:
+        user = User(acct=acct, mastodon_id=mastodon_id, display_name=display_name)
+        session.add(user)
+        session.flush()
+    else:
+        user.last_seen = utcnow()
+        if mastodon_id and not user.mastodon_id:
+            user.mastodon_id = mastodon_id
+        if display_name:
+            user.display_name = display_name
+    return user
+
+
+def block_user(session, acct):
+    user = get_or_create_user(session, acct)
+    user.blocked = True
+    return user
+
+
+def is_status_processed(session, status_id):
+    return session.get(ProcessedStatus, str(status_id)) is not None
+
+
+def mark_status_processed(session, status_id):
+    session.add(ProcessedStatus(status_id=str(status_id)))
+
+
+def prune_processed_statuses(session, before):
+    result = session.execute(delete(ProcessedStatus).where(ProcessedStatus.created_at < before))
+    return result.rowcount or 0
+
+
+def record_interaction(session, pet_id, user_id, command, status_id, accepted):
+    session.add(
+        Interaction(
+            pet_id=pet_id,
+            user_id=user_id,
+            command=command,
+            status_id=str(status_id) if status_id else None,
+            accepted=accepted,
+        )
+    )
+
+
+def _since(now):
+    return (now or utcnow()) - timedelta(hours=1)
+
+
+def count_user_interactions_last_hour(session, user_id, now=None):
+    stmt = (
+        select(func.count())
+        .select_from(Interaction)
+        .where(
+            Interaction.user_id == user_id,
+            Interaction.accepted.is_(True),
+            Interaction.created_at >= _since(now),
+        )
+    )
+    return session.scalar(stmt) or 0
+
+
+def count_global_interactions_last_hour(session, now=None):
+    stmt = (
+        select(func.count())
+        .select_from(Interaction)
+        .where(
+            Interaction.accepted.is_(True),
+            Interaction.created_at >= _since(now),
+        )
+    )
+    return session.scalar(stmt) or 0
+
+
+def count_interactions_total(session, command=None):
+    stmt = select(func.count()).select_from(Interaction).where(Interaction.accepted.is_(True))
+    if command:
+        stmt = stmt.where(Interaction.command == command)
+    return session.scalar(stmt) or 0
+
+
+def count_command_last_hour(session, command, now=None):
+    stmt = (
+        select(func.count())
+        .select_from(Interaction)
+        .where(
+            Interaction.command == command,
+            Interaction.accepted.is_(True),
+            Interaction.created_at >= _since(now),
+        )
+    )
+    return session.scalar(stmt) or 0
+
+
+def get_cooldown(session, pet_id, user_id, command):
+    stmt = select(Cooldown).where(
+        Cooldown.pet_id == pet_id,
+        Cooldown.command == command,
+        Cooldown.user_id.is_(None) if user_id is None else Cooldown.user_id == user_id,
+    )
+    return session.scalars(stmt).first()
+
+
+def touch_cooldown(session, pet_id, user_id, command, now=None):
+    now = now or utcnow()
+    cd = get_cooldown(session, pet_id, user_id, command)
+    if cd is None:
+        session.add(Cooldown(pet_id=pet_id, user_id=user_id, command=command, last_used_at=now))
+    else:
+        cd.last_used_at = now
+
+
+def log_event(session, pet_id, kind, detail=""):
+    session.add(Event(pet_id=pet_id, kind=kind, detail=detail))
+
+
+def get_settings(session, config):
+    settings = session.scalars(select(Settings).order_by(Settings.id)).first()
+    if settings is None:
+        settings = Settings(
+            autopost_enabled=config.autopost_enabled,
+            favourites_enabled=config.enable_favourites,
+            boosts_enabled=config.enable_boosts,
+            default_visibility=config.default_visibility,
+            autopost_visibility=config.autopost_visibility,
+        )
+        session.add(settings)
+        session.flush()
+    return settings
+
+
+def has_social_action(session, status_id, action):
+    stmt = select(SocialAction).where(
+        SocialAction.status_id == str(status_id), SocialAction.action == action
+    )
+    return session.scalars(stmt).first() is not None
+
+
+def record_social_action(session, pet_id, user_id, status_id, action, reason=""):
+    session.add(
+        SocialAction(
+            pet_id=pet_id,
+            user_id=user_id,
+            status_id=str(status_id),
+            action=action,
+            reason=reason,
+        )
+    )
+
+
+def count_social_actions(session, action, since, user_id=None):
+    stmt = select(func.count()).select_from(SocialAction).where(
+        SocialAction.action == action, SocialAction.created_at >= since
+    )
+    if user_id is not None:
+        stmt = stmt.where(SocialAction.user_id == user_id)
+    return session.scalar(stmt) or 0
+
+
+def last_social_action_at(session, action):
+    stmt = select(func.max(SocialAction.created_at)).where(SocialAction.action == action)
+    return session.scalar(stmt)
+
+
+def reset_world(session, config):
+    for model in (
+        EventVote,
+        CommunityEvent,
+        Milestone,
+        MemorySuggestion,
+        SocialAction,
+        Cooldown,
+        Interaction,
+        Event,
+        ProcessedStatus,
+        Settings,
+        User,
+    ):
+        session.execute(delete(model))
+    session.execute(delete(Pet))
+    session.flush()
+    get_settings(session, config)
+    return get_or_create_pet(session, config)
@@ -0,0 +1,230 @@+import random
+
+from . import personality as personality_mod
+from . import templates
+from .config import config
+
+# Back-compatible names; the data lives in templates.py.
+TEMPLATES = templates.COMMAND_TEMPLATES
+AUTOPOST = templates.AUTOPOST
+
+PACKS = {"default": (templates.COMMAND_TEMPLATES, templates.AUTOPOST)}
+
+
+def _pack():
+    return PACKS.get(config.personality_pack, PACKS["default"])
+
+
+def _personality(pet):
+    text = getattr(pet, "personality", None) or config.pet_personality
+    return personality_mod.from_string(text)
+
+
+def _render(pool, pet):
+    line = random.choice(pool)
+    return line.format(name=pet.name) if "{" in line else line
+
+
+def variant_for(command, pet):
+    p = _personality(pet)
+    if command == "feed":
+        if pet.hunger >= 75:
+            return "very_hungry"
+        if pet.hunger <= 12:
+            return "already_full"
+        if pet.chaos >= p.chaos_threshold:
+            return "chaotic"
+        if pet.trust <= p.suspicion_threshold:
+            return "suspicious"
+        if pet.happiness >= p.happy_threshold:
+            return "happy"
+        return "normal"
+
+    if command == "play":
+        if pet.energy <= p.tired_threshold:
+            return "tired"
+        if pet.chaos >= p.chaos_threshold:
+            return "chaotic"
+        if pet.happiness >= p.happy_threshold or pet.energy >= 75:
+            return "energetic"
+        return "normal"
+
+    if command == "pet":
+        if pet.asleep:
+            return "asleep"
+        if pet.trust <= p.suspicion_threshold:
+            return "low_trust"
+        if pet.happiness < 30 or pet.chaos >= p.chaos_threshold:
+            return "annoyed"
+        if pet.trust >= 70:
+            return "high_trust"
+        if pet.happiness >= p.happy_threshold:
+            return "affectionate"
+        return "normal"
+
+    if command == "clean":
+        if pet.cleanliness >= 85:
+            return "already_clean"
+        if pet.chaos >= p.chaos_threshold:
+            return "chaotic"
+        if pet.cleanliness <= 30:
+            return "dirty"
+        return "normal" if pet.trust >= 60 else "angry"
+
+    if command == "sleep":
+        if pet.energy >= 80:
+            return "too_energetic"
+        if pet.energy <= 30:
+            return "grateful"
+        return "normal"
+
+    if command == "wake":
+        if pet.energy < 20:
+            return "too_tired"
+        if pet.chaos >= p.chaos_threshold or pet.happiness < 35:
+            return "annoyed"
+        return "normal"
+
+    return "normal"
+
+
+def _status_variant(pet, p):
+    if pet.asleep:
+        return "asleep"
+    if pet.hunger >= 75:
+        return "hungry"
+    if pet.health < 30 or pet.happiness < 30:
+        return "sad"
+    if pet.energy <= 25:
+        return "tired"
+    if pet.chaos >= p.chaos_threshold:
+        return "chaotic"
+    if pet.cleanliness <= 30:
+        return "dirty"
+    if pet.happiness >= p.happy_threshold:
+        return "happy"
+    return "normal"
+
+
+def for_command(command, pet):
+    groups = _pack()[0][command]
+    variant = variant_for(command, pet)
+    return _render(groups.get(variant) or groups["normal"], pet)
+
+
+def status(pet):
+    groups = _pack()[0]["status"]
+    variant = _status_variant(pet, _personality(pet))
+    return _render(groups.get(variant) or groups["normal"], pet)
+
+
+def already_asleep(pet):
+    return _render(_pack()[0]["sleep"]["already_asleep"], pet)
+
+
+def already_awake(pet):
+    return _render(_pack()[0]["wake"]["already_awake"], pet)
+
+
+def too_sleepy(command, pet):
+    groups = _pack()[0].get(command, {})
+    return _render(groups.get("asleep") or templates.ASLEEP_REJECTION, pet)
+
+
+def cooldown(command, reason, pet):
+    return _render(templates.COOLDOWN.get(reason, templates.COOLDOWN["global"]), pet)
+
+
+def too_much(pet):
+    return _render(templates.TOO_MUCH, pet)
+
+
+def unknown(pet):
+    return _render(templates.UNKNOWN, pet)
+
+
+def nickname_set(pet, nick):
+    return random.choice(templates.NICKNAME_SET).format(name=pet.name, nick=nick)
+
+
+def nickname_cleared(pet):
+    return _render(templates.NICKNAME_CLEARED, pet)
+
+
+def nickname_rejected(pet):
+    return _render(templates.NICKNAME_REJECTED, pet)
+
+
+def teach_ack(pet):
+    return _render(templates.TEACH_ACK, pet)
+
+
+def teach_disabled(pet):
+    return _render(templates.TEACH_DISABLED, pet)
+
+
+def teach_rejected(pet):
+    return _render(templates.TEACH_REJECTED, pet)
+
+
+def no_event(pet):
+    return _render(templates.NO_EVENT, pet)
+
+
+def event_show(pet, title, description, options):
+    return templates.EVENT_SHOW.format(name=pet.name, title=title, description=description, options=options)
+
+
+def event_help(pet, title, options):
+    return templates.EVENT_HELP.format(name=pet.name, title=title, options=options)
+
+
+def vote_ack(pet, title):
+    return random.choice(templates.VOTE_ACK).format(name=pet.name, title=title)
+
+
+def vote_rejected(pet, reason, options):
+    if reason == "already voted":
+        return templates.VOTE_REJECTED_ALREADY.format(name=pet.name)
+    return templates.VOTE_REJECTED_UNKNOWN.format(name=pet.name, options=options)
+
+
+def maybe_address(text, user):
+    nick = getattr(user, "nickname", None)
+    if not nick:
+        return text
+    warm = getattr(user, "bond_level", "stranger") in ("friend", "beloved menace")
+    if random.random() < (0.3 if warm else 0.15):
+        return f"{nick}, {text}"
+    return text
+
+
+def autopost_group(pet):
+    p = _personality(pet)
+    if pet.asleep:
+        return "sleepy"
+    if pet.hunger >= 70:
+        return "hungry"
+    if pet.energy <= 25:
+        return "sleepy"
+    if pet.chaos >= p.chaos_threshold:
+        return "chaotic"
+    if pet.cleanliness <= 30:
+        return "dirty"
+    if pet.social <= 25:
+        return "lonely"
+    if pet.happiness >= p.happy_threshold:
+        return "happy"
+    return "weird_observation"
+
+
+def autopost(pet):
+    posts = _pack()[1]
+    group = autopost_group(pet)
+    flavor = ("weird_observation", "existential", "community_appreciation")
+    keys = [group] * 4 + list(flavor)
+    return _render(posts[random.choice(keys)], pet)
+
+
+def help_text(config):
+    return templates.HELP_TEXT.format(name=config.pet_name, species=config.pet_species)
@@ -0,0 +1,11 @@+from dataclasses import dataclass, field
+
+
+@dataclass
+class Result:
+    text: str
+    visibility: str
+    accepted: bool
+    favourite: bool = False
+    boost: bool = False
+    milestones: list = field(default_factory=list)
@@ -0,0 +1,60 @@+from datetime import timedelta
+
+from . import repository as repo
+from .models import utcnow
+from .sanitize import clean_nickname
+
+PRIVATE_VISIBILITIES = ("private", "direct")
+
+
+def may_reply(settings, user):
+    return not settings.paused and not user.blocked
+
+
+def may_autopost(settings):
+    return settings.autopost_enabled and not settings.paused
+
+
+def may_use_nickname(nickname):
+    return clean_nickname(nickname) is not None
+
+
+def may_store_memory(config):
+    return config.enable_teach_command
+
+
+def may_favourite(session, settings, config, user, visibility, status_id, now=None):
+    now = now or utcnow()
+    if not settings.favourites_enabled or settings.paused:
+        return False, "favourites disabled"
+    if user is None or user.blocked:
+        return False, "blocked user"
+    if visibility in PRIVATE_VISIBILITIES and not config.allow_favourite_private:
+        return False, "private status"
+    if repo.has_social_action(session, status_id, "favourite"):
+        return False, "already favourited"
+    if repo.count_social_actions(session, "favourite", now - timedelta(hours=1)) >= config.max_favourites_per_hour:
+        return False, "hourly cap reached"
+    if repo.count_social_actions(session, "favourite", now - timedelta(days=1), user.id) >= config.max_favourites_per_user_per_day:
+        return False, "per-user daily cap reached"
+    return True, "ok"
+
+
+def may_boost(session, settings, config, user, visibility, status_id, now=None):
+    now = now or utcnow()
+    if not settings.boosts_enabled or settings.paused:
+        return False, "boosts disabled"
+    if user is None or user.blocked:
+        return False, "blocked user"
+    if visibility in PRIVATE_VISIBILITIES:
+        return False, "private status"
+    if config.boost_only_public_statuses and visibility != "public":
+        return False, "not a public status"
+    if repo.has_social_action(session, status_id, "boost"):
+        return False, "already boosted"
+    if repo.count_social_actions(session, "boost", now - timedelta(days=1)) >= config.max_boosts_per_day:
+        return False, "daily boost cap reached"
+    last = repo.last_social_action_at(session, "boost")
+    if last and now - last < timedelta(hours=config.min_hours_between_boosts):
+        return False, "too soon since last boost"
+    return True, "ok"
@@ -0,0 +1,31 @@+import re
+
+_TAG = re.compile(r"<[^>]+>")
+_URL = re.compile(r"https?://|www\.", re.IGNORECASE)
+_WHITESPACE = re.compile(r"\s+")
+
+NICKNAME_MAX = 32
+
+
+def clean_nickname(raw):
+    if not raw:
+        return None
+    text = _WHITESPACE.sub(" ", _TAG.sub("", raw)).strip()
+    if not text:
+        return None
+    if "@" in text or _URL.search(text):
+        return None
+    return text[:NICKNAME_MAX]
+
+
+def clean_memory(raw, max_len=180, allow_urls=False):
+    if not raw:
+        return None
+    text = _WHITESPACE.sub(" ", _TAG.sub("", raw)).strip()
+    if len(text) >= 2 and text[0] in "\"'" and text[-1] == text[0]:
+        text = text[1:-1].strip()
+    if not text or "@" in text:
+        return None
+    if not allow_urls and _URL.search(text):
+        return None
+    return text[:max_len]
@@ -0,0 +1,82 @@+import logging
+from datetime import timedelta
+
+from apscheduler.schedulers.background import BackgroundScheduler
+
+from . import autopost, decay, events, milestones, safety
+from . import repository as repo
+from .config import config
+from .db import session_scope
+from .models import utcnow
+
+log = logging.getLogger("prami.scheduler")
+
+
+def run_decay():
+    with session_scope() as session:
+        pet = repo.get_pet(session)
+        if pet is None:
+            return
+        decay.tick(pet)
+        log.info("Decay — mood=%s hunger=%s energy=%s clean=%s", pet.mood, pet.hunger, pet.energy, pet.cleanliness)
+
+
+def run_autopost(client):
+    posts = []
+    with session_scope() as session:
+        settings = repo.get_settings(session, config)
+        pet = repo.get_pet(session)
+        if pet is None or not safety.may_autopost(settings):
+            return
+        visibility = settings.autopost_visibility
+
+        if config.enable_events:
+            for outcome in events.expire_due(session, pet, utcnow()):
+                milestones.award_event_completed(session, pet)
+                posts.append(outcome)
+            started = events.maybe_start(session, pet, config, utcnow())
+            if started:
+                posts.append(
+                    f"{started.title} {started.description} "
+                    f"Vote with '@{pet.name} vote <option>' — {events.options_text(started)}."
+                )
+
+        message = autopost.compose(session, pet, config)
+        repo.log_event(session, pet.id, "autopost", message)
+        posts.append(message)
+
+    for text in posts:
+        try:
+            client.post(text, visibility)
+        except Exception:
+            log.exception("Failed to autopost")
+    log.info("Autoposted %s message(s) (%s)", len(posts), visibility)
+
+
+def run_maintenance():
+    cutoff = utcnow() - timedelta(days=config.processed_retention_days)
+    with session_scope() as session:
+        removed = repo.prune_processed_statuses(session, cutoff)
+    if removed:
+        log.info("Pruned %s processed-status rows older than %s days", removed, config.processed_retention_days)
+
+
+def build_scheduler(client):
+    scheduler = BackgroundScheduler(timezone="UTC")
+    scheduler.add_job(
+        run_decay, "interval", minutes=config.decay_interval, id="decay", max_instances=1, coalesce=True
+    )
+    if config.autopost_enabled:
+        scheduler.add_job(
+            run_autopost,
+            "interval",
+            minutes=config.autopost_interval,
+            args=[client],
+            id="autopost",
+            max_instances=1,
+            coalesce=True,
+        )
+    scheduler.add_job(
+        run_maintenance, "interval", hours=24, id="maintenance", max_instances=1, coalesce=True
+    )
+    return scheduler
@@ -0,0 +1,12 @@+def favourite_worthy(command, before, user):
+    if command == "feed" and before["hunger"] >= 80:
+        return True
+    if command == "pet" and before["social"] <= 20:
+        return True
+    if command == "clean" and before["cleanliness"] <= 15:
+        return True
+    return False
+
+
+def boost_worthy(before_critical, after_critical):
+    return before_critical and not after_critical
@@ -0,0 +1,24 @@+def clamp(value, low=0, high=100):
+    return max(low, min(high, int(value)))
+
+
+def compute_mood(pet):
+    if pet.asleep:
+        return "asleep"
+    if pet.health < 25:
+        return "unwell"
+    if pet.hunger > 75:
+        return "ravenous"
+    if pet.energy < 20:
+        return "exhausted"
+    if pet.cleanliness < 25:
+        return "filthy"
+    if pet.chaos > 75:
+        return "feral"
+    if pet.happiness > 75 and pet.social > 60:
+        return "delighted"
+    if pet.social < 20:
+        return "lonely"
+    if pet.happiness < 30:
+        return "grumpy"
+    return "content"
@@ -0,0 +1,137 @@+from datetime import timedelta
+
+from sqlalchemy import func, select, text
+
+from . import events, memory
+from . import repository as repo
+from .models import Event, Interaction, User, utcnow
+
+
+def collect(session, config, settings, now=None):
+    now = now or utcnow()
+    pet = repo.get_pet(session)
+    day_ago = now - timedelta(hours=24)
+
+    last_startup = session.scalar(select(func.max(Event.created_at)).where(Event.kind == "startup"))
+
+    interactions_24h = session.scalar(
+        select(func.count()).select_from(Interaction).where(
+            Interaction.accepted.is_(True), Interaction.created_at >= day_ago
+        )
+    ) or 0
+    unique_users_24h = session.scalar(
+        select(func.count(func.distinct(Interaction.user_id))).where(
+            Interaction.accepted.is_(True), Interaction.created_at >= day_ago
+        )
+    ) or 0
+    autoposts_24h = session.scalar(
+        select(func.count()).select_from(Event).where(Event.kind == "autopost", Event.created_at >= day_ago)
+    ) or 0
+    blocked = session.scalar(select(func.count()).select_from(User).where(User.blocked.is_(True))) or 0
+
+    top = session.execute(
+        select(Interaction.command, func.count().label("n"))
+        .where(Interaction.accepted.is_(True))
+        .group_by(Interaction.command)
+        .order_by(func.count().desc())
+        .limit(5)
+    ).all()
+
+    active = events.active(session, now)
+
+    return {
+        "uptime_seconds": (now - last_startup).total_seconds() if last_startup else None,
+        "total_interactions": repo.count_interactions_total(session),
+        "interactions_24h": interactions_24h,
+        "unique_users_24h": unique_users_24h,
+        "mood": pet.mood,
+        "critical_stats": _critical_stats(pet),
+        "key_stats": {
+            "hunger": pet.hunger, "energy": pet.energy, "health": pet.health,
+            "cleanliness": pet.cleanliness, "social": pet.social,
+        },
+        "autoposts_24h": autoposts_24h,
+        "favourites_24h": repo.count_social_actions(session, "favourite", day_ago),
+        "boosts_24h": repo.count_social_actions(session, "boost", day_ago),
+        "active_event": active.title if active else None,
+        "pending_memories": len(memory.by_status(session, "pending")),
+        "blocked_users": blocked,
+        "top_commands": [(row[0], row[1]) for row in top],
+        "last_decay_at": pet.last_decay_at,
+        "last_autopost_at": pet.last_autopost_at,
+        "last_autopost_category": pet.last_autopost_category,
+        "database": _database_status(session, config),
+    }
+
+
+def _critical_stats(pet):
+    breaches = []
+    if pet.hunger >= 85:
+        breaches.append(f"hunger {pet.hunger}")
+    if pet.health <= 20:
+        breaches.append(f"health {pet.health}")
+    if pet.cleanliness <= 15:
+        breaches.append(f"cleanliness {pet.cleanliness}")
+    if pet.energy <= 10:
+        breaches.append(f"energy {pet.energy}")
+    return breaches
+
+
+def _database_status(session, config):
+    try:
+        session.execute(text("SELECT 1"))
+        dialect = config.database_url.split("://", 1)[0].split("+")[0] or "unknown"
+        return f"connected ({dialect})"
+    except Exception as exc:
+        return f"error: {exc}"
+
+
+def _fmt_dt(value):
+    return f"{value:%Y-%m-%d %H:%M} UTC" if value else "never"
+
+
+def _fmt_uptime(seconds):
+    if seconds is None:
+        return "—"
+    total = int(seconds)
+    days, total = divmod(total, 86400)
+    hours, total = divmod(total, 3600)
+    minutes = total // 60
+    parts = []
+    if days:
+        parts.append(f"{days}d")
+    if hours or days:
+        parts.append(f"{hours}h")
+    parts.append(f"{minutes}m")
+    return " ".join(parts)
+
+
+def render(data):
+    keys = data["key_stats"]
+    critical = ", ".join(data["critical_stats"]) or "none"
+    top = ", ".join(f"{cmd}:{n}" for cmd, n in data["top_commands"]) or "none"
+    last_post = _fmt_dt(data["last_autopost_at"])
+    if data["last_autopost_category"]:
+        last_post += f" ({data['last_autopost_category']})"
+
+    return "\n".join([
+        "Prami — local stats",
+        f"uptime: {_fmt_uptime(data['uptime_seconds'])}",
+        f"mood: {data['mood']} · critical: {critical}",
+        f"stats: hunger={keys['hunger']} energy={keys['energy']} health={keys['health']} "
+        f"clean={keys['cleanliness']} social={keys['social']}",
+        f"interactions: {data['total_interactions']} total · {data['interactions_24h']} in 24h · "
+        f"{data['unique_users_24h']} unique users 24h",
+        f"top commands: {top}",
+        f"autoposts 24h: {data['autoposts_24h']} · favourites 24h: {data['favourites_24h']} · "
+        f"boosts 24h: {data['boosts_24h']}",
+        f"active event: {data['active_event'] or 'none'}",
+        f"pending memories: {data['pending_memories']} · blocked users: {data['blocked_users']}",
+        f"last decay: {_fmt_dt(data['last_decay_at'])}",
+        f"last autopost: {last_post}",
+        f"database: {data['database']}",
+    ])
+
+
+def text_report(session, config, settings, now=None):
+    return render(collect(session, config, settings, now))
@@ -0,0 +1,384 @@+"""All of Prami's authorable text lives here. See docs/VOICE_AND_PERSONALITY.md
+before adding or editing lines. The logic that picks between them lives in responses.py."""
+
+COMMAND_TEMPLATES = {
+    "status": {
+        "normal": [
+            "{name} is awake, mildly suspicious of nothing in particular, and staring at the corner like the corner owes it money.",
+            "{name} is upright, employed by no one, and emotionally aerodynamic. A normal day.",
+            "{name} is awake and pretending to have a plan. We are all pretending along.",
+            "{name} is here, being a small good thing, and judging the furniture quietly.",
+        ],
+        "hungry": [
+            "{name} is awake, visibly hungry, and narrating the absence of snacks to anyone who'll listen.",
+            "{name} is hungry and dramatic about it, looking at you the way a tiny ghost looks at unfinished business.",
+            "{name} is hungry and has started referring to dinner, ominously, as 'the situation'.",
+        ],
+        "tired": [
+            "{name} is awake but barely, running on fumes and spite. Mostly spite.",
+            "{name} is sleepy-eyed and considering a nap with the seriousness of a major life decision.",
+            "{name} is low on energy and has entered its soft, dramatic, slightly tragic phase.",
+        ],
+        "happy": [
+            "{name} is awake, delighted, and convinced today is personally about it. It might be right.",
+            "{name} is happy, social, and radiating the confidence of a creature that has never read the news.",
+            "{name} is in a great mood and would like that on the record, ideally somewhere permanent.",
+        ],
+        "sad": [
+            "{name} is awake but a little gloomy, sitting in the shape of a small unanswered question.",
+            "{name} is not feeling great and is being quietly, theatrically brave about it.",
+            "{name} is low today. It would like some attention and possibly a snack, in that order.",
+        ],
+        "chaotic": [
+            "{name} is awake, vibrating slightly, and clearly about to make a decision none of us approved.",
+            "{name} has the look — the one that precedes a minor, lovable incident.",
+            "{name} is feral in a friendly way today, like static electricity with opinions.",
+        ],
+        "dirty": [
+            "{name} is awake, grubby, and weirdly proud of it. The dirt is allegedly load-bearing.",
+            "{name} is dirty, content, and treating the concept of a bath as a personal threat.",
+            "{name} is filthy and unbothered, radiating the calm of a creature with nothing to prove.",
+        ],
+        "asleep": [
+            "{name} is asleep in a compact loaf shape. Tiny dreams are happening. Some are probably illegal in three instances.",
+            "{name} is asleep, curled into a small parcel of unresolved feelings. Do not perceive it.",
+            "{name} is out cold, breathing softly, plotting nothing for once. Let it rest.",
+        ],
+    },
+    "feed": {
+        "normal": [
+            "{name} accepted the food with deep suspicion, then ate all of it, including the emotional subtext.",
+            "{name} ate, made brief eye contact, and filed the meal under 'good decisions made on its behalf'.",
+            "{name} took the snack with the dignity of a creature that absolutely had other options.",
+        ],
+        "very_hungry": [
+            "{name} inhaled the food like it held a personal grudge against hunger itself.",
+            "{name} ate so fast it briefly ascended, then asked, with its eyes, for more.",
+            "Finally. {name} accepted the food with the desperate gratitude of a tiny abandoned protagonist.",
+        ],
+        "already_full": [
+            "{name} is already full. It still considered eating it, but only for political reasons.",
+            "{name} looked at the food, looked at you, and declined with the grace of a creature making a point.",
+            "{name} is full and a little offended you'd suggest more. It ate a bit anyway. For morale.",
+        ],
+        "suspicious": [
+            "{name} sniffed the food, checked it for traps, found none, and remained suspicious on principle.",
+            "{name} accepted the snack while maintaining direct, faintly accusatory eye contact.",
+            "{name} ate, but wants it noted that trust was neither given nor implied.",
+        ],
+        "happy": [
+            "{name} ate happily and did a tiny pleased wiggle it will later deny under oath.",
+            "{name} accepted the food with delight and immediately became 12% more dramatic.",
+        ],
+        "chaotic": [
+            "{name} ate the food, then the idea of the food, then briefly considered the bowl.",
+            "{name} inhaled the snack and immediately looked around for witnesses.",
+        ],
+    },
+    "play": {
+        "normal": [
+            "{name} chased an invisible bug across the room. Nobody else saw it. {name} believes this reflects poorly on us.",
+            "{name} played for a glorious thirty seconds, then sat down to reflect on its achievements.",
+            "{name} engaged the toy, won decisively, and refused to explain the rules.",
+        ],
+        "energetic": [
+            "{name} performed one heroic jump, missed absolutely everything, and looked proud anyway.",
+            "{name} went fully feral with joy, did three laps of nothing, and is now thriving.",
+            "{name} played so hard the room felt smaller afterwards. Worth it. No notes.",
+        ],
+        "tired": [
+            "{name} tried to play, managed one unconvincing pounce, and lay down mid-thought.",
+            "{name} played at half speed, like a tiny dramatic film about a creature that needs a nap.",
+        ],
+        "chaotic": [
+            "{name} played, then knocked something over, then claimed it was always like that.",
+            "{name} turned a simple game into a low-stakes crime. We're all accomplices now.",
+        ],
+        "asleep": [
+            "{name} is asleep and the toy will have to wait. Negotiations resume after the nap.",
+            "{name} cracked one eye open, judged your timing, and returned to the void.",
+        ],
+    },
+    "pet": {
+        "normal": [
+            "{name} allowed the affection. This is legally distinct from admitting it wanted it.",
+            "{name} leaned in for exactly one second, then remembered its mysterious reputation.",
+            "{name} accepted the pets and is now quietly recalibrating its whole personality.",
+        ],
+        "affectionate": [
+            "{name} melted into your hand and made a sound that does not legally count as a purr.",
+            "{name} headbutted you gently, which is the closest it gets to a heartfelt confession.",
+            "{name} soaked up the affection shamelessly. Today, it has decided, love is fine.",
+        ],
+        "low_trust": [
+            "{name} accepted the pet but kept one eye open and one exit planned.",
+            "{name} tolerated the affection while clearly drafting a list of conditions.",
+            "{name} let you, but wants it known that this changes nothing between us.",
+        ],
+        "high_trust": [
+            "{name} flopped over completely, trusting you with the soft underside of its entire deal.",
+            "{name} leaned in without hesitation. You've earned the rare, full, undignified cuddle.",
+        ],
+        "annoyed": [
+            "{name} accepted one (1) pet and made it clear that was the whole allotment.",
+            "{name} allowed the affection with the energy of a creature doing you a favor.",
+        ],
+        "asleep": [
+            "{name} half-woke, accepted a sleepy pet, and dissolved back into the loaf.",
+            "{name} is asleep but leaned faintly into your hand. We will treasure this. It won't remember.",
+        ],
+    },
+    "clean": {
+        "normal": [
+            "{name} has been cleaned. It is shiny, betrayed, and already eyeing the nearest dirt.",
+            "{name} tolerated the cleaning with the grace of a small, damp storm cloud.",
+        ],
+        "dirty": [
+            "{name} really needed that. It is now clean, scandalized, and three shades lighter.",
+            "{name} was genuinely filthy and is now genuinely furious about the solution.",
+        ],
+        "already_clean": [
+            "{name} was already clean. It accepted a second bath as a personal attack and a waste of everyone's time.",
+            "{name} is already spotless and would like to file the bath under 'unnecessary roughness'.",
+        ],
+        "angry": [
+            "{name} is now clean, furious, and plotting a legally ambiguous response.",
+            "Bath completed. Trust temporarily damaged. Shine increased.",
+            "{name} has been washed against its will and wants the record, and possibly a lawyer, to reflect that.",
+        ],
+        "chaotic": [
+            "{name} turned the bath into a splash-based art installation. Everyone is wet. Nobody consented.",
+            "{name} is clean now, technically, but so is the floor, the wall, and your sleeve.",
+        ],
+    },
+    "sleep": {
+        "normal": [
+            "{name} curled into a compact loaf of unresolved feelings. Good night, tiny menace.",
+            "{name} powered down with great drama, as if today had asked too much of one small creature.",
+        ],
+        "already_asleep": [
+            "{name} is already asleep. Let it cook.",
+            "Shh. {name} is already out cold and dreaming of being slightly larger.",
+        ],
+        "too_energetic": [
+            "{name} lay down fizzing with leftover energy and is 'resting' with both eyes open and one foot twitching.",
+            "{name} is far too wired to sleep but agreed to lie down and be dramatic about it.",
+        ],
+        "grateful": [
+            "{name} sank into sleep instantly, deeply, and with audible relief. It needed this.",
+            "{name} curled up the second it was allowed and was gone before the sentence ended. Sweet dreams.",
+        ],
+    },
+    "wake": {
+        "normal": [
+            "{name} opened one eye. This was clearly not approved by management.",
+            "{name} stretched, yawned, and immediately started looking for trouble.",
+        ],
+        "already_awake": [
+            "{name} is already aggressively awake and patrolling for nonsense.",
+            "{name} has been up for a while and would like to know why you're asking.",
+        ],
+        "too_tired": [
+            "{name} woke up running on fumes, glared at the daylight, and filed a formal complaint.",
+            "{name} is awake but deeply against it. There was not nearly enough sleep for this.",
+        ],
+        "annoyed": [
+            "{name} woke up with the specific energy of a creature that had plans and resented yours.",
+            "{name} is awake now and somehow already disappointed in all of us.",
+        ],
+    },
+}
+
+AUTOPOST = {
+    "hungry": [
+        "{name} would like the community to know that it is brave, neglected, and only a little hungry.",
+        "Status: the snack situation remains unresolved. {name} is monitoring it closely, and tragically.",
+        "{name} is not saying it's hungry. It's just describing the snack-shaped hole in the universe.",
+    ],
+    "lonely": [
+        "It's very quiet here. Suspiciously quiet. Someone come keep {name} company.",
+        "{name} has been quiet for a while. This usually means growth, sleep, or a small crime.",
+        "{name} is just out here, a tiny creature, hoping someone says hello. No pressure. Some pressure.",
+    ],
+    "sleepy": [
+        "{name} is running low. If it blinks slowly, that's a system warning, not affection.",
+        "Nap incoming. {name} will return shorter-tempered and slightly more profound.",
+    ],
+    "chaotic": [
+        "{name} just discovered dust. Negotiations are ongoing.",
+        "Something is going to happen today, and {name} regrets to inform you that it is the something.",
+        "{name} has had an idea. The idea is bad. {name} is going to do it anyway.",
+    ],
+    "happy": [
+        "Great day to be a small creature surrounded by people who feed it. {name} gives today a 10/10.",
+        "{name} is having a wonderful time and wanted to tell everyone before the feeling files a complaint.",
+    ],
+    "dirty": [
+        "{name} has achieved a level of grubby it is, frankly, proud of. The dirt stays.",
+        "Reminder from {name}: it is dirty and thriving. The two are unrelated. Probably.",
+    ],
+    "existential": [
+        "{name} has been thinking about the timeline, and the timeline, and is unsure which one is worse.",
+        "{name} would like to know if anyone else can feel the days. Just it? Okay. Cool. Cool cool cool.",
+        "Sometimes {name} wonders if it is the pet or the pet is it. Then it sees a snack and the crisis resolves.",
+    ],
+    "community_appreciation": [
+        "{name} would like to thank everyone who has fed, pet, or simply tolerated it. You are its whole weird world.",
+        "{name} doesn't say it enough, but it's genuinely glad you're all here. That's all. Carry on.",
+    ],
+    "weird_observation": [
+        "{name} has decided the corner is plotting something. It respects this and will be watching.",
+        "{name} just learned that boxes exist and now nothing else matters.",
+        "{name} would like to report that a single leaf moved earlier and it has not recovered.",
+    ],
+}
+
+AUTOPOST_EVENT_REMINDER = (
+    "Reminder from {name}: '{title}' is still open. "
+    "Vote with '@{name} vote <option>' — {options}."
+)
+AUTOPOST_MEMORY_CALLBACK = "{name}, apropos of nothing: {memory}"
+
+ASLEEP_REJECTION = [
+    "{name} is asleep and that request has been quietly declined on its behalf.",
+    "{name} cracked one eye open, judged the timing, and went back to sleep. Try waking it first.",
+]
+
+COOLDOWN = {
+    "user": [
+        "Easy — {name} is still recovering from your last visit. Give it a beat.",
+        "{name} appreciates the enthusiasm but needs a moment before doing that again.",
+    ],
+    "global": [
+        "{name} is on cooldown for this one; the whole community's been busy.",
+        "Not yet. {name} needs the room to settle before doing that again.",
+    ],
+    "busy": [
+        "{name} has been getting a lot of this lately and needs to catch its breath.",
+        "Too popular right now. {name} is rate-limiting itself for its own good.",
+    ],
+}
+
+TOO_MUCH = [
+    "You've been very attentive. {name} loves you, but go drink some water — we'll both be here.",
+    "{name} is touched by the dedication and gently suggests a small break.",
+]
+
+UNKNOWN = [
+    "{name} tilted its head. That wasn't a command it knows. Try @{name} help.",
+    "{name} heard you, did not understand you, and loves you anyway. Try @{name} help.",
+]
+
+NICKNAME_SET = [
+    "{name} will call you {nick} now. It wrote it down somewhere mostly safe.",
+    "Noted. To {name}, you are {nick} from now on. Wear it with suspicious pride.",
+    "{nick} it is. {name} will deploy it when the moment feels sufficiently dramatic.",
+]
+
+NICKNAME_CLEARED = [
+    "{name} forgot your nickname on purpose. You are a mystery again.",
+    "Nickname cleared. {name} will go back to squinting at you fondly.",
+]
+
+NICKNAME_REJECTED = [
+    "{name} couldn't use that one (too long, or it had links or mentions). Try something plainer.",
+    "That nickname didn't pass {name}'s vibe check. Keep it short, plain, and link-free.",
+]
+
+TEACH_ACK = [
+    "{name} filed your idea away for a human to look at later. No promises, but it's listening.",
+    "Noted and pending. A moderator will decide if {name} truly believes that. It hopes so.",
+    "{name} squinted thoughtfully and added that to the maybe-pile. Thank you for the lore.",
+]
+
+TEACH_DISABLED = [
+    "{name} isn't taking lessons right now. Teaching is turned off on this instance.",
+    "That's sweet, but {name}'s teach mode is currently closed. Maybe later.",
+]
+
+TEACH_REJECTED = [
+    "{name} couldn't accept that (too long, or it had links or mentions). Keep it short and plain.",
+    "That one didn't pass the filter — no links or mentions, and keep it under the limit.",
+]
+
+NO_EVENT = [
+    "No event right now. {name} is suspiciously calm and probably scheming.",
+    "Nothing happening at the moment. {name} is between dramas.",
+]
+
+VOTE_ACK = [
+    "{name} counted your vote on '{title}'. Democracy, but tiny.",
+    "Vote recorded. {name} pretends not to care, but it's refreshing the results.",
+]
+
+VOTE_REJECTED_ALREADY = "{name} already has your vote. One per creature-crisis, please."
+VOTE_REJECTED_UNKNOWN = "{name} didn't recognise that option. Try one of: {options}."
+
+EVENT_SHOW = "{title} {description} Vote with: {options} — reply '@{name} vote <option>'."
+EVENT_HELP = "Current event: {title} Cast your vote with '@{name} vote <option>'. Options: {options}."
+
+HELP_TEXT = (
+    "I'm {name}, a {species} the whole community looks after. Mention me:\n"
+    "status · how I'm doing · feed · play · pet · clean · sleep · wake\n"
+    "Be gentle with the snacks. I bruise emotionally."
+)
+
+# Community event scenarios: title/description/options + per-option outcome text and
+# the stat deltas the winning option applies. Pure content + tuning, no logic.
+EVENT_SCENARIOS = [
+    {
+        "key": "egg",
+        "title": "Prami found a mysterious egg.",
+        "description": "It is warm and humming faintly. What should Prami do?",
+        "options": {
+            "hatch": {"label": "help it hatch", "deltas": {"happiness": 10, "chaos": 8},
+                      "text": "The egg hatched into more questions. Prami is thrilled and mildly concerned."},
+            "guard": {"label": "guard it", "deltas": {"trust": 5, "energy": -5},
+                      "text": "Prami guarded the egg all night. Nothing happened. Prami calls this a triumph."},
+        },
+    },
+    {
+        "key": "server_room",
+        "title": "Prami is lost in the server room.",
+        "description": "It's cold, blinking, and full of cables. How do we get Prami out?",
+        "options": {
+            "call": {"label": "call to it", "deltas": {"social": 10, "happiness": 6},
+                     "text": "Prami followed your voices out, pretending it was never lost."},
+            "snack": {"label": "leave a snack trail", "deltas": {"hunger": -15, "happiness": 8},
+                      "text": "The snack trail worked. Of course it did. Prami emerged smug and fed."},
+        },
+    },
+    {
+        "key": "floorboards",
+        "title": "Prami heard a noise under the floorboards.",
+        "description": "Investigate bravely or ignore bravely?",
+        "options": {
+            "investigate": {"label": "investigate", "deltas": {"chaos": 10, "energy": -8},
+                            "text": "It was nothing. Probably. Prami is now a detective with trust issues."},
+            "ignore": {"label": "ignore it", "deltas": {"happiness": -4, "chaos": 4},
+                       "text": "Prami ignored it valiantly while staring at the floor for three hours."},
+        },
+    },
+    {
+        "key": "snack_vote",
+        "title": "Prami needs help choosing a snack.",
+        "description": "Two options, high stakes, no wrong answers except one.",
+        "options": {
+            "soup": {"label": "soup", "deltas": {"hunger": -20, "happiness": 8},
+                     "text": "Soup won. Prami declares soup a weather condition and a lifestyle."},
+            "crunch": {"label": "something crunchy", "deltas": {"hunger": -18, "chaos": 6},
+                       "text": "Crunchy won. The aftermath was loud. Prami regrets nothing."},
+        },
+    },
+    {
+        "key": "fort",
+        "title": "Prami is building a tiny blanket fort.",
+        "description": "What is the fort for?",
+        "options": {
+            "nap": {"label": "napping", "deltas": {"energy": 10, "happiness": 6},
+                    "text": "The fort is for naps. Prami has retired there indefinitely."},
+            "plans": {"label": "plotting", "deltas": {"chaos": 10, "trust": 3},
+                      "text": "The fort is a planning HQ. The plans are small and lovingly chaotic."},
+        },
+    },
+]
@@ -0,0 +1,10 @@+[project]
+name = "prami"
+version = "0.1.0"
+description = "A federated community virtual pet for Mastodon / ActivityPub"
+requires-python = ">=3.11"
+license = { text = "AGPL-3.0-or-later" }
+
+[tool.pytest.ini_options]
+pythonpath = ["."]
+testpaths = ["tests"]
@@ -0,0 +1,2 @@+-r requirements.txt
+pytest>=7.4
@@ -0,0 +1,5 @@+Mastodon.py>=1.8.1
+SQLAlchemy>=2.0,<3.0
+psycopg2-binary>=2.9
+python-dotenv>=1.0
+APScheduler>=3.10,<4.0
@@ -0,0 +1,33 @@+import pytest
+
+from prami import repository as repo
+from prami.config import Config
+from prami.db import create_all, init_engine, session_scope
+
+
+@pytest.fixture
+def config():
+    cfg = Config()
+    cfg.database_url = "sqlite:///:memory:"
+    cfg.max_per_user_hour = 100
+    cfg.max_global_hour = 1000
+    cfg.default_visibility = "unlisted"
+    return cfg
+
+
+@pytest.fixture
+def session(config):
+    init_engine(config.database_url)
+    create_all()
+    with session_scope() as s:
+        yield s
+
+
+@pytest.fixture
+def pet(session, config):
+    return repo.get_or_create_pet(session, config)
+
+
+@pytest.fixture
+def user(session):
+    return repo.get_or_create_user(session, "tester@example.social", mastodon_id="1")
@@ -0,0 +1,116 @@+import pytest
+
+from prami import cli, engine, memory
+from prami import repository as repo
+from prami.actions.registry import REGISTRY, build_registry
+from prami.config import Config, config as app_config
+from prami.db import session_scope
+from prami.parser import parse
+
+
+# --- Registry: alias resolution -------------------------------------------------
+
+def test_aliases_resolve_to_canonical_names():
+    assert REGISTRY.canonical("food") == "feed"
+    assert REGISTRY.canonical("pat") == "pet"
+    assert REGISTRY.canonical("nick") == "nickname"
+    assert REGISTRY.canonical("?") == "help"
+    assert REGISTRY.canonical("unmapped") is None
+
+
+def test_phrase_aliases_resolve():
+    phrases = REGISTRY.phrase_map()
+    assert phrases.get("call me") == "nickname"
+    assert phrases.get("name me") == "nickname"
+
+
+def test_resolve_falls_back_to_unknown():
+    assert REGISTRY.resolve("feed").name == "feed"
+    assert REGISTRY.resolve("zzz").name == "unknown"
+
+
+def test_a_fresh_registry_has_every_command():
+    registry = build_registry()
+    for name in ("status", "feed", "play", "pet", "clean", "sleep", "wake",
+                 "nickname", "teach", "event", "vote", "admin", "help"):
+        assert name in registry.names()
+
+
+# --- Disabled commands ----------------------------------------------------------
+
+def test_teach_is_config_gated():
+    cfg = Config()
+    cfg.enable_teach_command = False
+    assert REGISTRY.is_enabled("teach", cfg) is False
+    cfg.enable_teach_command = True
+    assert REGISTRY.is_enabled("teach", cfg) is True
+
+
+def test_disabled_command_does_not_execute(session, pet, user, config):
+    config.enable_teach_command = False
+    result = engine.handle(session, pet, user, "teach", config, arg="Prami fears printers")
+    assert result.accepted is False
+    assert memory.by_status(session, "pending") == []
+
+
+# --- Admin-only actions ---------------------------------------------------------
+
+def test_admin_action_is_admin_only():
+    assert REGISTRY.resolve("admin").admin_only is True
+    assert REGISTRY.resolve("feed").admin_only is False
+
+
+def test_nonadmin_cannot_run_admin_action(session, pet, user, config):
+    config.admin_accounts = []
+    assert engine.handle(session, pet, user, "admin", config, arg="pause") is None
+
+
+def test_admin_action_runs_for_admin(session, pet, user, config):
+    config.admin_accounts = [user.acct]
+    result = engine.handle(session, pet, user, "admin", config, arg="status")
+    assert result is not None and "paused=" in result.text
+
+
+# --- CLI and Mastodon share the same core path ----------------------------------
+
+def test_parsed_alias_resolves_to_same_action():
+    parsed = parse("<p>@prami food</p>")
+    assert REGISTRY.resolve(parsed.name) is REGISTRY.resolve("feed")
+
+
+def test_engine_dispatches_through_registry(session, pet, user, config, monkeypatch):
+    seen = []
+    original = REGISTRY.resolve
+    monkeypatch.setattr(REGISTRY, "resolve", lambda name: seen.append(name) or original(name))
+    engine.handle(session, pet, user, "feed", config)
+    assert "feed" in seen
+
+
+@pytest.fixture
+def cli_db(tmp_path, monkeypatch):
+    monkeypatch.setattr(app_config, "database_url", f"sqlite:///{tmp_path / 'cli.db'}")
+    cli.main(["reset", "--yes"])
+
+
+def test_cli_runs_through_engine_handle(cli_db, monkeypatch):
+    seen = []
+    original = engine.handle
+    monkeypatch.setattr(engine, "handle", lambda *a, **k: seen.append(a[3]) or original(*a, **k))
+    cli.main(["feed", "--user", "@alice@example.com"])
+    assert "feed" in seen
+
+
+def test_cli_and_mastodon_paths_reach_same_state(cli_db):
+    cli.main(["feed", "--user", "@cli@x.com"])
+    with session_scope() as s:
+        cli_hunger = repo.get_pet(s).hunger
+
+    cli.main(["reset", "--yes"])
+    parsed = parse("<p>@prami food</p>")  # what the Mastodon poller feeds the engine
+    with session_scope() as s:
+        pet = repo.get_pet(s)
+        user = repo.get_or_create_user(s, "masto@x.com")
+        engine.handle(s, pet, user, parsed.name, app_config, arg=parsed.arg)
+        masto_hunger = pet.hunger
+
+    assert cli_hunger == masto_hunger == 5
@@ -0,0 +1,69 @@+from prami import engine, memory
+from prami import repository as repo
+
+
+def as_admin(config, user):
+    config.admin_accounts = [user.acct]
+
+
+def test_nonadmin_is_ignored(session, pet, user, config):
+    config.admin_accounts = []
+    assert engine.handle(session, pet, user, "admin", config, arg="pause") is None
+
+
+def test_nonadmin_refusal_when_configured(session, pet, user, config):
+    config.admin_accounts = []
+    config.admin_refuse_nonadmin = True
+    result = engine.handle(session, pet, user, "admin", config, arg="pause")
+    assert result is not None and result.accepted is False
+
+
+def test_admin_pause_and_resume(session, pet, user, config):
+    as_admin(config, user)
+    engine.handle(session, pet, user, "admin", config, arg="pause")
+    assert repo.get_settings(session, config).paused is True
+    engine.handle(session, pet, user, "admin", config, arg="resume")
+    assert repo.get_settings(session, config).paused is False
+
+
+def test_admin_pause_blocks_other_users(session, pet, user, config):
+    as_admin(config, user)
+    engine.handle(session, pet, user, "admin", config, arg="pause")
+    other = repo.get_or_create_user(session, "bob@example.social")
+    assert engine.handle(session, pet, other, "feed", config) is None
+
+
+def test_admin_toggle_favourites(session, pet, user, config):
+    as_admin(config, user)
+    engine.handle(session, pet, user, "admin", config, arg="set-favourites on")
+    assert repo.get_settings(session, config).favourites_enabled is True
+
+
+def test_admin_set_visibility(session, pet, user, config):
+    as_admin(config, user)
+    engine.handle(session, pet, user, "admin", config, arg="set-visibility public")
+    assert repo.get_settings(session, config).default_visibility == "public"
+
+
+def test_admin_approve_memory(session, pet, user, config):
+    as_admin(config, user)
+    config.enable_teach_command = True
+    other = repo.get_or_create_user(session, "carol@example.social")
+    suggestion = memory.submit(session, pet, other, "Prami likes blankets", config)
+    result = engine.handle(session, pet, user, "admin", config, arg=f"approve-memory {suggestion.id}")
+    assert "Approved" in result.text
+    assert memory.get(session, suggestion.id).status == "approved"
+
+
+def test_admin_block_and_unblock(session, pet, user, config):
+    as_admin(config, user)
+    engine.handle(session, pet, user, "admin", config, arg="block @troll@bad.social")
+    assert repo.get_user(session, "troll@bad.social").blocked is True
+    engine.handle(session, pet, user, "admin", config, arg="unblock @troll@bad.social")
+    assert repo.get_user(session, "troll@bad.social").blocked is False
+
+
+def test_admin_status_text(session, pet, user, config):
+    as_admin(config, user)
+    result = engine.handle(session, pet, user, "admin", config, arg="status")
+    assert "paused=" in result.text
@@ -0,0 +1,38 @@+from prami import autopost, events, memory
+
+
+def test_compose_sets_last_category(session, pet, config):
+    text = autopost.compose(session, pet, config)
+    assert text
+    assert pet.last_autopost_category is not None
+
+
+def test_compose_avoids_immediate_repeat(session, pet, config):
+    seen = set()
+    for _ in range(8):
+        autopost.compose(session, pet, config)
+        seen.add(pet.last_autopost_category)
+    assert len(seen) > 1
+
+
+def test_active_event_reminder_is_reachable(session, pet, config):
+    config.enable_events = True
+    template = next(t for t in events.TEMPLATES if t["key"] == "egg")
+    events.start(session, pet, config, template=template)
+    seen = set()
+    for _ in range(60):
+        pet.last_autopost_category = None
+        autopost.compose(session, pet, config)
+        seen.add(pet.last_autopost_category)
+    assert "active_event_reminder" in seen
+
+
+def test_approved_memory_callback_is_reachable(session, pet, user, config):
+    suggestion = memory.submit(session, pet, user, "soup is a weather condition", config)
+    memory.approve(session, suggestion.id, "admin")
+    seen = set()
+    for _ in range(80):
+        pet.last_autopost_category = None
+        autopost.compose(session, pet, config)
+        seen.add(pet.last_autopost_category)
+    assert "approved_memory_callback" in seen
@@ -0,0 +1,108 @@+import pytest
+
+from prami import cli
+from prami import repository as repo
+from prami.config import config
+from prami.db import session_scope
+
+
+@pytest.fixture
+def cli_db(tmp_path, monkeypatch):
+    monkeypatch.setattr(config, "database_url", f"sqlite:///{tmp_path / 'cli.db'}")
+    cli.main(["reset", "--yes"])
+
+
+def current_pet():
+    with session_scope() as session:
+        pet = repo.get_pet(session)
+        session.expunge(pet)
+        return pet
+
+
+def test_feed_changes_state_through_cli(cli_db, capsys):
+    cli.main(["feed", "--user", "@alice@example.com"])
+    out = capsys.readouterr().out.strip()
+    assert out
+    assert current_pet().hunger == 5
+
+
+def test_status_is_narrative_not_raw_numbers(cli_db, capsys):
+    cli.main(["status", "--user", "@alice@example.com"])
+    out = capsys.readouterr().out
+    assert "Prami" in out
+    assert "hunger :" not in out
+
+
+def test_debug_state_exposes_raw_numbers(cli_db, capsys):
+    cli.main(["debug-state"])
+    out = capsys.readouterr().out
+    for field in ("hunger", "happiness", "energy", "health", "cleanliness", "social", "trust", "chaos", "asleep", "mood", "updated_at"):
+        assert field in out
+
+
+def test_feed_cooldown_blocks_second_call(cli_db, capsys):
+    cli.main(["feed", "--user", "@alice@example.com"])
+    cli.main(["feed", "--user", "@alice@example.com"])
+    assert current_pet().hunger == 5
+
+
+def test_different_users_are_independent(cli_db):
+    cli.main(["feed", "--user", "@alice@example.com"])
+    cli.main(["feed", "--user", "@bob@example.com"])
+    assert current_pet().hunger == 0
+
+
+def test_tick_applies_decay(cli_db):
+    before = current_pet().hunger
+    cli.main(["tick"])
+    assert current_pet().hunger > before
+
+
+def test_tick_hours_scales_with_decay_interval(cli_db):
+    cli.main(["tick", "--hours", "3"])
+    assert current_pet().hunger == 100
+
+
+def test_sleep_and_wake_toggle_state(cli_db):
+    cli.main(["sleep", "--user", "@alice@example.com"])
+    assert current_pet().asleep is True
+    cli.main(["wake", "--user", "@alice@example.com"])
+    assert current_pet().asleep is False
+
+
+def test_reset_restores_defaults(cli_db):
+    cli.main(["feed", "--user", "@alice@example.com"])
+    cli.main(["reset", "--yes"])
+    assert current_pet().hunger == 40
+
+
+def test_cli_nickname_sets_and_appears_in_relationship(cli_db, capsys):
+    cli.main(["nickname", "Soup", "Wizard", "--user", "@alice@example.com"])
+    capsys.readouterr()
+    cli.main(["relationship", "--user", "@alice@example.com"])
+    out = capsys.readouterr().out
+    assert "Soup Wizard" in out
+    assert "bond_level" in out
+
+
+def test_cli_relationship_tracks_interaction_counts(cli_db, capsys):
+    cli.main(["feed", "--user", "@alice@example.com"])
+    cli.main(["pet", "--user", "@alice@example.com"])
+    capsys.readouterr()
+    cli.main(["relationship", "--user", "@alice@example.com"])
+    out = capsys.readouterr().out
+    assert "total_interactions" in out
+    assert "trust_score" in out
+
+
+def test_blocked_user_gets_no_response(cli_db, capsys):
+    monkey_block(["alice@example.com"])
+    cli.main(["feed", "--user", "@alice@example.com"])
+    out = capsys.readouterr().out
+    assert "blocked" in out.lower()
+
+
+def monkey_block(accts):
+    with session_scope() as session:
+        for acct in accts:
+            repo.block_user(session, acct)
@@ -0,0 +1,115 @@+from datetime import timedelta
+
+from prami import engine
+from prami import repository as repo
+from prami.models import utcnow
+from prami.state import compute_mood
+
+
+def test_feed_reduces_hunger_and_is_accepted(session, pet, user, config):
+    pet.hunger = 80
+    result = engine.handle(session, pet, user, "feed", config)
+    assert result.accepted is True
+    assert pet.hunger == 45
+
+
+def test_stats_never_leave_bounds(session, pet, user, config):
+    pet.hunger = 10
+    engine.handle(session, pet, user, "feed", config)
+    assert pet.hunger == 0
+    assert 0 <= pet.happiness <= 100
+
+
+def test_overfeeding_raises_chaos(session, pet, user, config):
+    pet.hunger = 20
+    pet.chaos = 30
+    engine.handle(session, pet, user, "feed", config)
+    assert pet.chaos > 30
+
+
+def test_play_drops_energy_and_raises_chaos(session, pet, user, config):
+    pet.energy = 80
+    pet.chaos = 20
+    engine.handle(session, pet, user, "play", config)
+    assert pet.energy == 65
+    assert pet.chaos == 25
+
+
+def test_clean_costs_some_happiness(session, pet, user, config):
+    pet.cleanliness = 20
+    pet.happiness = 60
+    engine.handle(session, pet, user, "clean", config)
+    assert pet.cleanliness == 60
+    assert pet.happiness == 54
+
+
+def test_user_cooldown_blocks_second_feed(session, pet, user, config):
+    first = engine.handle(session, pet, user, "feed", config)
+    second = engine.handle(session, pet, user, "feed", config)
+    assert first.accepted is True
+    assert second.accepted is False
+
+
+def test_cooldown_clears_after_window(session, pet, user, config):
+    now = utcnow()
+    engine.handle(session, pet, user, "feed", config, now=now)
+    again = engine.handle(session, pet, user, "feed", config, now=now + timedelta(minutes=31))
+    assert again.accepted is True
+
+
+def test_global_per_command_rate_limit(session, pet, user, config):
+    now = utcnow()
+    for i in range(5):
+        other = repo.get_or_create_user(session, f"u{i}@example.social")
+        result = engine.handle(session, pet, other, "clean", config, now=now)
+        assert result.accepted is True
+    blocked = repo.get_or_create_user(session, "late@example.social")
+    sixth = engine.handle(session, pet, blocked, "clean", config, now=now)
+    assert sixth.accepted is False
+
+
+def test_sleep_then_wake_toggles_state(session, pet, user, config):
+    engine.handle(session, pet, user, "sleep", config)
+    assert pet.asleep is True
+    engine.handle(session, pet, user, "wake", config)
+    assert pet.asleep is False
+
+
+def test_sleep_global_cooldown_applies_across_users(session, pet, user, config):
+    now = utcnow()
+    engine.handle(session, pet, user, "sleep", config, now=now)
+    engine.handle(session, pet, user, "wake", config, now=now)
+    other = repo.get_or_create_user(session, "second@example.social")
+    blocked = engine.handle(session, pet, other, "sleep", config, now=now + timedelta(minutes=1))
+    assert blocked.accepted is False
+
+
+def test_active_care_rejected_while_asleep(session, pet, user, config):
+    pet.asleep = True
+    result = engine.handle(session, pet, user, "play", config)
+    assert result.accepted is False
+    assert pet.asleep is True
+
+
+def test_blocked_user_is_silently_ignored(session, pet, user, config):
+    user.blocked = True
+    assert engine.handle(session, pet, user, "feed", config) is None
+
+
+def test_per_user_hourly_cap(session, pet, user, config):
+    config.max_per_user_hour = 1
+    engine.handle(session, pet, user, "status", config)
+    capped = engine.handle(session, pet, user, "status", config)
+    assert capped.accepted is False
+
+
+def test_unknown_command_still_responds(session, pet, user, config):
+    result = engine.handle(session, pet, user, None, config)
+    assert result is not None
+    assert result.accepted is True
+
+
+def test_mood_reflects_hunger(session, pet):
+    pet.hunger = 90
+    pet.asleep = False
+    assert compute_mood(pet) == "ravenous"
@@ -0,0 +1,69 @@+from datetime import timedelta
+
+from prami import engine, events
+from prami.models import utcnow
+
+
+def make_event(session, pet, config, key="egg"):
+    template = next(t for t in events.TEMPLATES if t["key"] == key)
+    return events.start(session, pet, config, template=template)
+
+
+def test_start_creates_active_event(session, pet, config):
+    event = make_event(session, pet, config)
+    assert event.status == "active"
+    assert events.active(session).id == event.id
+
+
+def test_only_one_active_event(session, pet, config):
+    make_event(session, pet, config)
+    assert events.maybe_start(session, pet, config, force=True) is None
+
+
+def test_vote_one_per_user(session, pet, user, config):
+    event = make_event(session, pet, config)
+    ok, _ = events.vote(session, event, user, "hatch")
+    assert ok is True
+    again, reason = events.vote(session, event, user, "guard")
+    assert again is False and reason == "already voted"
+
+
+def test_vote_unknown_option(session, pet, user, config):
+    event = make_event(session, pet, config)
+    ok, reason = events.vote(session, event, user, "banana")
+    assert ok is False and reason == "unknown option"
+
+
+def test_complete_applies_winning_outcome(session, pet, user, config):
+    event = make_event(session, pet, config, key="snack_vote")
+    pet.hunger = 80
+    events.vote(session, event, user, "soup")
+    outcome = events.complete(session, event, pet)
+    assert event.status == "completed"
+    assert pet.hunger == 60
+    assert "Soup" in outcome
+
+
+def test_complete_without_votes_uses_first_option(session, pet, config):
+    event = make_event(session, pet, config, key="egg")
+    assert events.complete(session, event, pet)
+    assert event.status == "completed"
+
+
+def test_expire_due_completes_past_events(session, pet, config):
+    event = make_event(session, pet, config)
+    event.ends_at = utcnow() - timedelta(hours=1)
+    done = events.expire_due(session, pet)
+    assert len(done) == 1
+    assert event.status == "completed"
+
+
+def test_engine_vote_flow(session, pet, user, config):
+    make_event(session, pet, config, key="snack_vote")
+    result = engine.handle(session, pet, user, "vote", config, arg="soup")
+    assert result.accepted is True
+
+
+def test_engine_event_show_when_none(session, pet, user, config):
+    result = engine.handle(session, pet, user, "event", config)
+    assert result.text
@@ -0,0 +1,62 @@+from prami import engine, memory, sanitize
+
+
+def test_clean_memory_strips_quotes_and_html():
+    assert sanitize.clean_memory('"<b>Soup is a weather condition.</b>"') == "Soup is a weather condition."
+
+
+def test_clean_memory_rejects_url_and_mention():
+    assert sanitize.clean_memory("visit http://x.example") is None
+    assert sanitize.clean_memory("ask @bob") is None
+
+
+def test_clean_memory_truncates():
+    assert len(sanitize.clean_memory("x" * 300, max_len=180)) == 180
+
+
+def test_categorize():
+    assert memory.categorize("Prami is afraid of silent printers") == "fear"
+    assert memory.categorize("Prami believes soup is a weather condition") == "lore"
+    assert memory.categorize("Prami likes warm blankets") == "preference"
+
+
+def test_submit_and_approve_flow(session, pet, user, config):
+    suggestion = memory.submit(session, pet, user, "Prami believes soup is weather.", config)
+    assert suggestion.status == "pending"
+    assert memory.by_status(session, "pending") == [suggestion]
+    memory.approve(session, suggestion.id, "admin")
+    assert suggestion.status == "approved"
+    assert memory.by_status(session, "approved") == [suggestion]
+
+
+def test_reject_records_reason(session, pet, user, config):
+    suggestion = memory.submit(session, pet, user, "something forgettable", config)
+    memory.reject(session, suggestion.id, "admin", "spam")
+    assert suggestion.status == "rejected"
+    assert suggestion.rejection_reason == "spam"
+
+
+def test_approve_nonpending_returns_none(session, pet, user, config):
+    suggestion = memory.submit(session, pet, user, "x", config)
+    memory.approve(session, suggestion.id, "admin")
+    assert memory.approve(session, suggestion.id, "admin") is None
+
+
+def test_teach_disabled_by_default(session, pet, user, config):
+    result = engine.handle(session, pet, user, "teach", config, arg="Prami fears printers")
+    assert result.accepted is False
+    assert memory.by_status(session, "pending") == []
+
+
+def test_teach_creates_pending_when_enabled(session, pet, user, config):
+    config.enable_teach_command = True
+    result = engine.handle(session, pet, user, "teach", config, arg="Prami fears printers")
+    assert result.accepted is True
+    assert len(memory.by_status(session, "pending")) == 1
+
+
+def test_teach_rejects_invalid_content(session, pet, user, config):
+    config.enable_teach_command = True
+    result = engine.handle(session, pet, user, "teach", config, arg="see http://x.example")
+    assert result.accepted is False
+    assert memory.by_status(session, "pending") == []
@@ -0,0 +1,55 @@+from datetime import timedelta
+
+from prami import engine, milestones
+from prami.models import utcnow
+
+
+def test_first_interaction_awarded(session, pet, user):
+    user.total_interactions = 1
+    achieved = milestones.check(session, pet, user, "status", False)
+    assert any(m["key"] == "first_interaction" for m in achieved)
+
+
+def test_milestone_not_repeated(session, pet, user):
+    user.total_interactions = 1
+    milestones.check(session, pet, user, "status", False)
+    again = milestones.check(session, pet, user, "status", False)
+    assert all(m["key"] != "first_interaction" for m in again)
+
+
+def test_critical_save_is_favourite_worthy(session, pet, user):
+    user.total_interactions = 5
+    achieved = milestones.check(session, pet, user, "feed", was_critical=True)
+    save = [m for m in achieved if m["key"] == "first_critical_save"]
+    assert save and save[0]["favourite"] is True
+
+
+def test_bond_milestone(session, pet, user):
+    user.total_interactions = 5
+    user.bond_level = "friend"
+    achieved = milestones.check(session, pet, user, "pet", False)
+    assert any(m["key"] == "reached_friend" for m in achieved)
+
+
+def test_first_feeding_via_engine(session, pet, user, config):
+    pet.hunger = 50
+    result = engine.handle(session, pet, user, "feed", config)
+    keys = {m["key"] for m in result.milestones}
+    assert "first_feeding" in keys
+
+
+def test_seven_days_alive(session, pet, user, config):
+    pet.born_at = utcnow() - timedelta(days=8)
+    result = engine.handle(session, pet, user, "status", config)
+    assert any(m["key"] == "7_days_alive" for m in result.milestones)
+
+
+def test_major_milestone_is_boost_eligible(session, pet, user):
+    user.total_interactions = 5
+    # force the all-time interaction count past 100 by seeding interactions
+    from prami import repository as repo
+    for i in range(101):
+        repo.record_interaction(session, pet.id, user.id, "pet", None, True)
+    achieved = milestones.check(session, pet, user, "pet", False)
+    hundred = [m for m in achieved if m["key"] == "100_interactions"]
+    assert hundred and hundred[0]["boost"] is True
@@ -0,0 +1,73 @@+from datetime import timedelta
+
+from prami import engine, responses, sanitize
+from prami.models import utcnow
+
+
+def test_clean_nickname_valid():
+    assert sanitize.clean_nickname("Soup Wizard") == "Soup Wizard"
+
+
+def test_clean_nickname_truncates_to_32():
+    assert len(sanitize.clean_nickname("x" * 50)) == 32
+
+
+def test_clean_nickname_rejects_urls():
+    assert sanitize.clean_nickname("see http://evil.example") is None
+    assert sanitize.clean_nickname("www.evil.example") is None
+
+
+def test_clean_nickname_rejects_mentions():
+    assert sanitize.clean_nickname("hi @bob@server.social") is None
+
+
+def test_clean_nickname_strips_html():
+    assert sanitize.clean_nickname("<b>Soup</b>") == "Soup"
+
+
+def test_clean_nickname_collapses_breaks():
+    assert sanitize.clean_nickname("Soup\n\n  Wizard") == "Soup Wizard"
+
+
+def test_clean_nickname_empty_is_none():
+    assert sanitize.clean_nickname("   ") is None
+
+
+def test_nickname_set_then_clear(session, pet, user, config):
+    result = engine.handle(session, pet, user, "nickname", config, arg="Soup Wizard")
+    assert result.accepted is True
+    assert user.nickname == "Soup Wizard"
+
+    later = utcnow() + timedelta(minutes=6)
+    engine.handle(session, pet, user, "nickname", config, arg="clear", now=later)
+    assert user.nickname is None
+
+
+def test_nickname_invalid_is_rejected(session, pet, user, config):
+    result = engine.handle(session, pet, user, "nickname", config, arg="http://x.example")
+    assert result.accepted is False
+    assert user.nickname is None
+
+
+def test_nickname_cooldown_blocks_quick_second(session, pet, user, config):
+    engine.handle(session, pet, user, "nickname", config, arg="One")
+    second = engine.handle(session, pet, user, "nickname", config, arg="Two")
+    assert second.accepted is False
+    assert user.nickname == "One"
+
+
+class _FakeUser:
+    def __init__(self, nickname, bond_level="stranger"):
+        self.nickname = nickname
+        self.bond_level = bond_level
+
+
+def test_maybe_address_can_use_nickname():
+    u = _FakeUser("Soup", "friend")
+    used = any(responses.maybe_address("Prami did a thing.", u).startswith("Soup, ") for _ in range(200))
+    assert used
+
+
+def test_maybe_address_without_nickname_is_unchanged():
+    u = _FakeUser(None)
+    assert responses.maybe_address("hello", u) == "hello"
@@ -0,0 +1,67 @@+import pytest
+
+from prami.parser import parse, parse_command, strip_html
+
+
+@pytest.mark.parametrize(
+    "content,expected",
+    [
+        ("<p>@prami feed</p>", "feed"),
+        ("<p>@prami@instance.social PLAY now please</p>", "play"),
+        ("@prami status", "status"),
+        ("<p>hey @prami can you <b>eat</b> something</p>", "feed"),
+        ("@prami pat the creature", "pet"),
+        ("@prami WAKE UP", "wake"),
+        ("@prami help", "help"),
+        ("@prami ?", "help"),
+    ],
+)
+def test_recognized_commands(content, expected):
+    assert parse_command(content) == expected
+
+
+def test_unknown_command_returns_none():
+    assert parse_command("<p>@prami good morning friend</p>") is None
+
+
+def test_first_command_wins():
+    assert parse_command("@prami feed and then play") == "feed"
+
+
+def test_strip_html_unescapes_and_drops_tags():
+    assert strip_html("<p>tom &amp; jerry</p>").strip() == "tom & jerry"
+
+
+def test_mentions_are_not_read_as_commands():
+    assert parse_command("@feed @prami pet") == "pet"
+
+
+def test_empty_content_is_none():
+    assert parse_command("") is None
+
+
+def test_parse_nickname_with_argument():
+    result = parse("<p>@prami nickname Soup Wizard</p>")
+    assert result.name == "nickname"
+    assert result.arg == "Soup Wizard"
+
+
+def test_parse_call_me_phrase():
+    result = parse("@prami call me Soup")
+    assert result.name == "nickname"
+    assert result.arg == "Soup"
+
+
+def test_parse_name_me_phrase():
+    result = parse("@prami name me Captain")
+    assert (result.name, result.arg) == ("nickname", "Captain")
+
+
+def test_parse_nickname_clear():
+    result = parse("@prami nickname clear")
+    assert (result.name, result.arg) == ("nickname", "clear")
+
+
+def test_parse_plain_command_has_empty_arg():
+    result = parse("@prami feed")
+    assert (result.name, result.arg) == ("feed", "")
@@ -0,0 +1,52 @@+from prami import engine
+from prami.relationships import bond_for, is_critical, record
+
+
+def test_bond_levels_by_trust():
+    assert bond_for(0) == "stranger"
+    assert bond_for(11) == "stranger"
+    assert bond_for(12) == "familiar"
+    assert bond_for(30) == "trusted"
+    assert bond_for(55) == "friend"
+    assert bond_for(80) == "beloved menace"
+
+
+def test_record_counts_and_trust(pet, user):
+    record(user, "pet", pet)
+    assert user.total_interactions == 1
+    assert user.pet_count == 1
+    assert user.trust_score == 4
+    assert user.bond_level == "stranger"
+
+
+def test_bond_progresses_with_care(pet, user):
+    for _ in range(3):
+        record(user, "pet", pet)
+    assert user.trust_score == 12
+    assert user.bond_level == "familiar"
+
+
+def test_non_care_command_counts_but_no_per_command_field(pet, user):
+    record(user, "status", pet)
+    assert user.total_interactions == 1
+    assert user.feed_count == 0
+    assert user.trust_score == 0
+
+
+def test_critical_save_grants_bonus(pet, user):
+    record(user, "feed", pet, was_critical=True)
+    assert user.trust_score == 9
+
+
+def test_is_critical_detects_bad_states(pet):
+    pet.hunger, pet.health, pet.cleanliness, pet.energy = 90, 90, 70, 70
+    assert is_critical(pet) is True
+    pet.hunger = 40
+    assert is_critical(pet) is False
+
+
+def test_engine_records_relationship(session, pet, user, config):
+    engine.handle(session, pet, user, "feed", config)
+    assert user.feed_count == 1
+    assert user.total_interactions == 1
+    assert user.trust_score >= 3
@@ -0,0 +1,116 @@+import pytest
+
+from prami import responses
+
+
+def set_state(pet, **kwargs):
+    for key, value in kwargs.items():
+        setattr(pet, key, value)
+    return pet
+
+
+def test_every_template_pool_is_non_empty():
+    for command, groups in responses.TEMPLATES.items():
+        assert "normal" in groups, f"{command} is missing a normal variant"
+        for variant, pool in groups.items():
+            assert pool, f"{command}/{variant} is empty"
+    for group, pool in responses.AUTOPOST.items():
+        assert pool, f"autopost/{group} is empty"
+
+
+@pytest.mark.parametrize(
+    "command,state,expected",
+    [
+        ("feed", {"hunger": 90}, "very_hungry"),
+        ("feed", {"hunger": 5}, "already_full"),
+        ("feed", {"hunger": 40, "trust": 5}, "suspicious"),
+        ("play", {"energy": 8}, "tired"),
+        ("play", {"energy": 85, "happiness": 50}, "energetic"),
+        ("pet", {"trust": 5}, "low_trust"),
+        ("pet", {"trust": 85, "happiness": 50, "chaos": 10}, "high_trust"),
+        ("clean", {"cleanliness": 95}, "already_clean"),
+        ("clean", {"cleanliness": 10, "chaos": 10}, "dirty"),
+        ("sleep", {"energy": 90}, "too_energetic"),
+        ("sleep", {"energy": 15}, "grateful"),
+        ("wake", {"energy": 10}, "too_tired"),
+    ],
+)
+def test_variant_selection_matches_state(pet, command, state, expected):
+    set_state(pet, **state)
+    assert responses.variant_for(command, pet) == expected
+
+
+def test_personality_shifts_chaotic_threshold(pet):
+    set_state(pet, hunger=40, trust=40, happiness=50, chaos=58)
+
+    pet.personality = "calm and gentle"
+    assert responses.variant_for("feed", pet) == "normal"
+
+    pet.personality = "chaotic, weird"
+    assert responses.variant_for("feed", pet) == "chaotic"
+
+
+def test_responses_actually_vary(pet):
+    set_state(pet, hunger=40, energy=70, happiness=50, chaos=10, trust=40)
+    seen = {responses.for_command("feed", pet) for _ in range(40)}
+    assert len(seen) > 1
+
+
+def test_response_stays_short(pet):
+    for _ in range(30):
+        line = responses.for_command("feed", pet)
+        assert line.count(".") <= 4
+        assert len(line) < 300
+
+
+@pytest.mark.parametrize(
+    "state",
+    [
+        {"asleep": True},
+        {"hunger": 90},
+        {"energy": 10},
+        {"happiness": 95, "energy": 70},
+        {"happiness": 10},
+        {"chaos": 95},
+        {"cleanliness": 5},
+        {},
+    ],
+)
+def test_status_handles_all_states(pet, state):
+    set_state(pet, **state)
+    line = responses.status(pet)
+    assert line
+    assert pet.name in line
+
+
+def test_pet_name_is_applied(pet):
+    pet.name = "Glorbo"
+    assert "Glorbo" in responses.status(pet)
+
+
+@pytest.mark.parametrize(
+    "state,expected",
+    [
+        ({"hunger": 90}, "hungry"),
+        ({"asleep": True}, "sleepy"),
+        ({"social": 10, "hunger": 40, "energy": 70, "chaos": 10, "cleanliness": 70}, "lonely"),
+        ({"cleanliness": 10, "hunger": 40, "energy": 70, "chaos": 10, "social": 60}, "dirty"),
+        ({"chaos": 95, "hunger": 40, "energy": 70}, "chaotic"),
+    ],
+)
+def test_autopost_group_reflects_state(pet, state, expected):
+    set_state(pet, **state)
+    assert responses.autopost_group(pet) == expected
+
+
+def test_autopost_returns_varied_lines(pet):
+    set_state(pet, hunger=40, energy=70, happiness=50, social=60, chaos=10, cleanliness=70)
+    seen = {responses.autopost(pet) for _ in range(60)}
+    assert len(seen) > 1
+
+
+def test_asleep_rejection_and_no_op_responses(pet):
+    pet.asleep = True
+    assert responses.too_sleepy("feed", pet)
+    assert responses.already_asleep(pet)
+    assert responses.already_awake(pet)
@@ -0,0 +1,90 @@+from prami import repository as repo
+from prami import safety
+
+
+def settings_with(session, config, **overrides):
+    settings = repo.get_settings(session, config)
+    for key, value in overrides.items():
+        setattr(settings, key, value)
+    return settings
+
+
+def test_favourites_disabled_by_default(session, pet, user, config):
+    settings = repo.get_settings(session, config)
+    allowed, _ = safety.may_favourite(session, settings, config, user, "public", "1")
+    assert allowed is False
+
+
+def test_favourite_allowed_when_enabled(session, pet, user, config):
+    settings = settings_with(session, config, favourites_enabled=True)
+    allowed, why = safety.may_favourite(session, settings, config, user, "public", "1")
+    assert allowed is True
+    assert why == "ok"
+
+
+def test_favourite_rejects_blocked_user(session, pet, user, config):
+    settings = settings_with(session, config, favourites_enabled=True)
+    user.blocked = True
+    allowed, _ = safety.may_favourite(session, settings, config, user, "public", "1")
+    assert allowed is False
+
+
+def test_favourite_rejects_private_by_default(session, pet, user, config):
+    settings = settings_with(session, config, favourites_enabled=True)
+    allowed, _ = safety.may_favourite(session, settings, config, user, "direct", "1")
+    assert allowed is False
+
+
+def test_favourite_allows_private_when_configured(session, pet, user, config):
+    config.allow_favourite_private = True
+    settings = settings_with(session, config, favourites_enabled=True)
+    allowed, _ = safety.may_favourite(session, settings, config, user, "private", "1")
+    assert allowed is True
+
+
+def test_favourite_no_duplicate(session, pet, user, config):
+    settings = settings_with(session, config, favourites_enabled=True)
+    repo.record_social_action(session, pet.id, user.id, "9", "favourite")
+    allowed, why = safety.may_favourite(session, settings, config, user, "public", "9")
+    assert allowed is False
+    assert "already" in why
+
+
+def test_favourite_hourly_cap(session, pet, user, config):
+    config.max_favourites_per_hour = 1
+    settings = settings_with(session, config, favourites_enabled=True)
+    repo.record_social_action(session, pet.id, user.id, "1", "favourite")
+    allowed, _ = safety.may_favourite(session, settings, config, user, "public", "2")
+    assert allowed is False
+
+
+def test_boosts_disabled_by_default(session, pet, user, config):
+    settings = repo.get_settings(session, config)
+    allowed, _ = safety.may_boost(session, settings, config, user, "public", "1")
+    assert allowed is False
+
+
+def test_boost_only_public(session, pet, user, config):
+    settings = settings_with(session, config, boosts_enabled=True)
+    allowed, _ = safety.may_boost(session, settings, config, user, "unlisted", "1")
+    assert allowed is False
+
+
+def test_boost_allowed_for_public_when_enabled(session, pet, user, config):
+    settings = settings_with(session, config, boosts_enabled=True)
+    allowed, why = safety.may_boost(session, settings, config, user, "public", "1")
+    assert allowed is True
+
+
+def test_boost_respects_min_interval(session, pet, user, config):
+    config.max_boosts_per_day = 5
+    settings = settings_with(session, config, boosts_enabled=True)
+    repo.record_social_action(session, pet.id, user.id, "1", "boost")
+    allowed, why = safety.may_boost(session, settings, config, user, "public", "2")
+    assert allowed is False
+
+
+def test_paused_blocks_favourites(session, pet, user, config):
+    settings = settings_with(session, config, favourites_enabled=True, paused=True)
+    allowed, _ = safety.may_favourite(session, settings, config, user, "public", "1")
+    assert allowed is False
@@ -0,0 +1,24 @@+from prami import social
+
+
+def test_favourite_worthy_feed_when_starving():
+    assert social.favourite_worthy("feed", {"hunger": 90, "social": 50, "cleanliness": 70}, None) is True
+    assert social.favourite_worthy("feed", {"hunger": 50, "social": 50, "cleanliness": 70}, None) is False
+
+
+def test_favourite_worthy_pet_when_lonely():
+    assert social.favourite_worthy("pet", {"hunger": 40, "social": 10, "cleanliness": 70}, None) is True
+
+
+def test_favourite_worthy_clean_when_filthy():
+    assert social.favourite_worthy("clean", {"hunger": 40, "social": 50, "cleanliness": 10}, None) is True
+
+
+def test_favourite_not_worthy_for_ordinary_play():
+    assert social.favourite_worthy("play", {"hunger": 40, "social": 50, "cleanliness": 70}, None) is False
+
+
+def test_boost_worthy_only_on_critical_save():
+    assert social.boost_worthy(True, False) is True
+    assert social.boost_worthy(False, False) is False
+    assert social.boost_worthy(True, True) is False
@@ -0,0 +1,94 @@+from datetime import timedelta
+
+from prami import decay, engine, events, memory, stats
+from prami import repository as repo
+from prami.models import utcnow
+
+
+def _collect(session, config):
+    return stats.collect(session, config, repo.get_settings(session, config), utcnow())
+
+
+def test_counts_interactions_and_unique_users(session, pet, config):
+    a = repo.get_or_create_user(session, "a@x.com")
+    b = repo.get_or_create_user(session, "b@x.com")
+    repo.record_interaction(session, pet.id, a.id, "feed", None, True)
+    repo.record_interaction(session, pet.id, b.id, "pet", None, True)
+    repo.record_interaction(session, pet.id, a.id, "feed", None, True)
+
+    data = _collect(session, config)
+    assert data["total_interactions"] == 3
+    assert data["interactions_24h"] == 3
+    assert data["unique_users_24h"] == 2
+    assert ("feed", 2) in data["top_commands"]
+
+
+def test_social_memory_and_blocked_counts(session, pet, user, config):
+    repo.record_social_action(session, pet.id, user.id, "1", "favourite")
+    repo.record_social_action(session, pet.id, user.id, "2", "boost")
+    config.enable_teach_command = True
+    memory.submit(session, pet, user, "Prami likes soup", config)
+    repo.block_user(session, "troll@bad.social")
+
+    data = _collect(session, config)
+    assert data["favourites_24h"] == 1
+    assert data["boosts_24h"] == 1
+    assert data["pending_memories"] == 1
+    assert data["blocked_users"] == 1
+
+
+def test_active_event_and_mood(session, pet, config):
+    pet.mood = "ravenous"
+    template = next(t for t in events.TEMPLATES if t["key"] == "egg")
+    events.start(session, pet, config, template=template)
+
+    data = _collect(session, config)
+    assert data["active_event"] == "Prami found a mysterious egg."
+    assert data["mood"] == "ravenous"
+
+
+def test_critical_stats_are_listed(session, pet, config):
+    pet.hunger = 95
+    pet.energy = 5
+    data = _collect(session, config)
+    assert any("hunger" in entry for entry in data["critical_stats"])
+    assert any("energy" in entry for entry in data["critical_stats"])
+
+
+def test_uptime_is_none_without_startup(session, pet, config):
+    assert _collect(session, config)["uptime_seconds"] is None
+
+
+def test_uptime_from_startup_event(session, pet, config):
+    repo.log_event(session, pet.id, "startup", "started")
+    session.flush()
+    data = stats.collect(session, config, repo.get_settings(session, config), utcnow() + timedelta(hours=2))
+    assert data["uptime_seconds"] >= 7000
+
+
+def test_last_decay_tracked(session, pet, config):
+    assert _collect(session, config)["last_decay_at"] is None
+    decay.tick(pet)
+    assert _collect(session, config)["last_decay_at"] is not None
+
+
+def test_database_status_connected(session, pet, config):
+    assert "connected" in _collect(session, config)["database"]
+
+
+def test_render_is_readable(session, pet, config):
+    report = stats.text_report(session, config, repo.get_settings(session, config), utcnow())
+    for label in ("uptime", "mood", "interactions", "database", "active event"):
+        assert label in report
+
+
+def test_admin_stats_command(session, pet, user, config):
+    config.admin_accounts = [user.acct]
+    result = engine.handle(session, pet, user, "admin", config, arg="stats")
+    assert result is not None
+    assert "uptime" in result.text and "database" in result.text
+
+
+def test_stats_not_available_to_non_admin(session, pet, user, config):
+    config.admin_accounts = []
+    assert engine.handle(session, pet, user, "admin", config, arg="stats") is None
@@ -0,0 +1,26 @@+from prami import templates
+
+
+def test_every_command_pool_is_well_formed():
+    for command, variants in templates.COMMAND_TEMPLATES.items():
+        assert "normal" in variants, f"{command} is missing a normal variant"
+        for variant, pool in variants.items():
+            assert pool, f"{command}/{variant} is empty"
+            assert all(isinstance(line, str) and line.strip() for line in pool)
+
+
+def test_autopost_pools_are_non_empty():
+    for group, pool in templates.AUTOPOST.items():
+        assert pool, f"autopost/{group} is empty"
+
+
+def test_event_scenarios_are_well_formed():
+    keys = set()
+    for scenario in templates.EVENT_SCENARIOS:
+        assert scenario["key"] and scenario["key"] not in keys
+        keys.add(scenario["key"])
+        assert scenario["title"] and scenario["description"]
+        assert scenario["options"]
+        for option in scenario["options"].values():
+            assert option["label"] and option["text"]
+            assert isinstance(option["deltas"], dict)