mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 09:58:06 +00:00
Compare commits
25 Commits
v1.3.7
...
release/v1
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c9147b437 | |||
| e803e77bfe | |||
| 8b57691edf | |||
| fd8276e4aa | |||
| 4737aa10e2 | |||
| d759e74cca | |||
| 010a081d56 | |||
| f550bb2cca | |||
| 551a423322 | |||
| 6b9a7f36fd | |||
| 70f03354a2 | |||
| 4f9370ae7a | |||
| c67336c376 | |||
| bb16f19ea5 | |||
| 2c7ab6a187 | |||
| 9311b41dd3 | |||
| 996e70a64d | |||
| 68b773ad61 | |||
| 18324f6e80 | |||
| 8c5c0a25a6 | |||
| fdfba4eaa7 | |||
| 215804cf2c | |||
| ca5b04532d | |||
| 01232a66da | |||
| 3a5f6e1634 |
661
LICENSE
Normal file
661
LICENSE
Normal file
@ -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/>.
|
||||
56
README.md
56
README.md
@ -190,16 +190,43 @@ internal/
|
||||
├── config/ SQLite 配置中心
|
||||
├── events/ 事件总线
|
||||
└── internal/lua/adapters/ 8 个 LLM 协议适配器脚本
|
||||
外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库,使用 `plugindev` 工具链开发,参考 `example/` 目录下的 Go 和 Lua 示例
|
||||
外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库,使用 `hmapdev` 工具链开发,参考 `example/` 目录下的 Go 和 Lua 示例
|
||||
```
|
||||
|
||||
## 项目状态
|
||||
|
||||
**v1.2.0** — 统一多模态向量空间 + 媒体升为图记忆一等节点 + 数据面全量迁到共享内存。
|
||||
|
||||
- **模型中立的统一向量空间**:内核不再适配任何具体模型,只提供公共 provider SPI
|
||||
(`pkg/embedding`:`Modality` / `Input{Data,MIME}` / `Info{Dimension,Fingerprint,Modalities}`
|
||||
+ 名字注册表),实现在 `providers/*`。默认 **Chinese-CLIP ViT-B/16** —— text 与 image
|
||||
落在**同一空间**(512 维、指纹 `cd2a495cf990`、Apache-2.0、独立实测常驻约 1.15GB);
|
||||
`qwen3vl` 保留(2048 维、常驻约 9.4GB,供内存充足或将来要视频的机器切回)。
|
||||
文本检索仍由既有词向量 / TF-IDF 兜底:CLIP 双塔的**纯文本语义弱于 MLLM 型嵌入器**,
|
||||
这是已知并写进文档的代价。
|
||||
- **媒体是图数据库的一等节点与边**:**彻底删除**「用文本描述式索引图片」这套将就机制,
|
||||
以及 `media_refs` 与媒体引用计数。记忆块遵循单层不变量——Context → Document → Graph
|
||||
是块的**迁移**,不是复制、也不靠引用保活。
|
||||
- **数据面全部走共享内存**(工具调用帧 / Cleaner / 输入输出通道 / 媒体块 / 文档与知识正文),
|
||||
RPC 只传偏移描述符;**RPC 协议升到 2**,fd3 布局改变,**不支持滚动升级**——
|
||||
内核与全部插件必须同批重建、同批安装,存量插件须用新版 `hmapdev` 重编。
|
||||
- 注入可声明 `InjectOptions{NoMemory, ContextPolicy}`(**默认仍记入记忆、默认不裁剪**);
|
||||
裁剪必须显式声明,且先经插件注册的 `Cleaner`。SDK 1.2.0 相对 1.1.0 **纯追加**。
|
||||
- **发行包默认启用** ONNX 向量空间,并把模型(754MB)与 ONNX Runtime(24MB)随
|
||||
server/full 包发布;`homed` 放弃 Windows 原生支持改走 WSL2;jieba 词库内嵌进二进制。
|
||||
- 修掉三个**安装链静默失败**:`initconfig` 因 `CGO_ENABLED=0` 是空操作(打印凭据却一个字节
|
||||
没写)、全新安装被误判「已有配置」而整体跳过默认值播种(装完 0 插件)、deb 的 `postinst`
|
||||
查错 unit 路径导致 `enable` 从未执行。
|
||||
- 自本版起以 **AGPL-3.0-only** 发布(含网络条款;插件静态链接 SDK 故须同许可,见「许可」)。
|
||||
|
||||
> 以下历史条目保留原文以呈现演进,其中两条机制**已在 v1.2.0 移除**:
|
||||
> 「媒体以 `[<mime> <短digest>] <描述>` 标记参与检索」(描述式索引)与「媒体引用计数式 GC」。
|
||||
|
||||
**v1.1.1** — 多模态贯通**插件边界**。v1.1.0 让记忆系统支持了二进制多媒体节点,但那条链路只对内核自己开放;本版打通到插件与模型。公开 SDK 新增媒体字段与三个媒体注入接口(配套 [SDK v1.1.0](https://gitcode.com/JianFeeeee/homeagent-sdk/releases/tag/v1.1.0),整条 1.1.x 线共用),内核实现对应四个 RPC。桥接层此前在**静默裁字段**:插件交进来的 `Confidence`/类型/`SentenceText` 全被丢弃、`Doc` 只留三个字段、`Remove` 不解引用(媒体永久算「被引用」,GC 收不掉)。`processTextInput`/`processMediaInput` 归一成一条 `processInput`,媒体路径由此获得它一直缺的去重、`no_memory`、通道 `Cleaner`、中断语义、`EventRawInput`。修掉三处真实缺陷:**用户发的图从来没出现在 WebUI 聊天记录里**(媒体路径发布 map 而订阅方断言 string)、**`memory_commit` 的 `sentence_text` 从未暴露给模型**(而它是媒体绑定链的必经环节)、**`PluginSDK` 两处并发竞态**(`-race` 实测 11 处,插件重载瞬间偶发 nil 解引用崩溃)。
|
||||
|
||||
**v1.1.0** — 记忆系统支持**二进制多媒体节点**。内容寻址媒体存储(CAS + SQLite 元数据 + 磁盘 blob,`Get` always 重校 digest),贯通 L0(上下文事件)/L2(文档)/L3(图谱句子)三层,引用计数式 GC(有引用者绝不删)。视觉模型生成的描述文本是持久语义记忆,blob 只是可被容量 GC 淘汰的缓存。
|
||||
|
||||
**v1.0.0** — 外部插件从 C ABI 动态库迁移到**子进程 + 共享内存**。首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件须用新版 `plugindev` 重编为 `plugin.bin`,**业务代码零改动**)。消除 6 类此前在生产造成故障的缺陷:热重载失效(`DF_1_NODELETE` 让 `dlclose` 成 no-op)、崩溃隔离缺失(插件 panic 带崩 homed)、stage lost update(副本模型丢失 35.8~36.8%)、cgo 超时不可中断(线程线性泄漏)、`output_send` 假成功(模型收到「已发送」而消息未送达)、Windows 能力断层(只见 3 个 stage 字段且无法写回)。三面通信:stdio JSON-RPC(控制)+ 共享内存段(数据)+ 事件环(通知);权限梯度显式化为三道闸。RPC 往返 p50 24.1µs,崩溃到恢复 <1s。
|
||||
**v1.0.0** — 外部插件从 C ABI 动态库迁移到**子进程 + 共享内存**。首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件须用新版工具链重编;该工具链当时名为 `plugindev`,**现名 `hmapdev`**)。外部插件需重编为 `plugin.bin`,**业务代码零改动**)。消除 6 类此前在生产造成故障的缺陷:热重载失效(`DF_1_NODELETE` 让 `dlclose` 成 no-op)、崩溃隔离缺失(插件 panic 带崩 homed)、stage lost update(副本模型丢失 35.8~36.8%)、cgo 超时不可中断(线程线性泄漏)、`output_send` 假成功(模型收到「已发送」而消息未送达)、Windows 能力断层(只见 3 个 stage 字段且无法写回)。三面通信:stdio JSON-RPC(控制)+ 共享内存段(数据)+ 事件环(通知);权限梯度显式化为三道闸。RPC 往返 p50 24.1µs,崩溃到恢复 <1s。
|
||||
|
||||
**v0.9.0** — C ABI v2:外部插件 Stage 回调支持写回(`invoke_stage` 增加 result 输出,插件可在 OnInput/AfterToolcall/PostAction 修改 RawMessage/LLMText/ToolResults 等并同步回内核),ABI 版本随内核 minor 对齐(v0.9.x → ABIVersion=2,`version_min=1` 向后兼容旧插件)。同步修复工具循环 zen 兼容补位误伤首轮 system 上下文的问题。配套 SDK 提供增强版 sanitizer 示例(坏 UTF-8/U+FFFD/ANSI 转义全链路清洗)。**该 ABI 已随 v1.0.0 退场。**
|
||||
|
||||
@ -224,7 +251,7 @@ internal/
|
||||
| **client** | waiter + 桌面 GUI | 连接远程 HomeAgent |
|
||||
|
||||
- Linux:`.deb`(amd64/arm64)、`.rpm`(x86_64)、`.tar.gz`
|
||||
- Windows:`HomeAgent_v1.1.1_{Full,Server,Client}_win64.exe`(NSIS 安装向导)
|
||||
- Windows:`HomeAgent_v1.2.0_{Full,Server,Client}_win64.exe`(NSIS 安装向导,含 AGPL 许可页)。自 v1.2.0 起因 `homed` 不再支持 Windows 原生(依赖 fd 继承与共享内存段内偏移解引用),安装器改为引导到 **WSL2**,并把 Linux 包送进发行版里按 Linux 方式安装。
|
||||
- 免安装:`homeagent-bin-<os>_<arch>.tar.gz`(含 homed/waiter/initconfig)
|
||||
- 校验:`SHA256SUMS`
|
||||
|
||||
@ -239,3 +266,26 @@ make install # 安装到系统
|
||||
```
|
||||
|
||||
依赖:Go 1.25+, CGo (go-sqlite3), Linux/Windows。
|
||||
|
||||
## 许可
|
||||
|
||||
本项目以 **GNU Affero 通用公共许可证第 3 版(AGPL-3.0-only)** 发布,全文见 [LICENSE](LICENSE)。
|
||||
|
||||
它是 GPL 家族里**传染性最强**的一档:不仅分发时须提供完整对应源码,
|
||||
**通过网络提供服务时也要向使用者提供源码**(§13 Remote Network Interaction)。
|
||||
即:任何人把改过的 HomeAgent 对外提供网络服务,都必须让该服务的使用者拿到改动后的源码。
|
||||
|
||||
插件与本项目通过公开 SDK **静态链接**(SDK 源码会进入插件二进制),因此插件是本项目的
|
||||
衍生作品,需以相同许可发布;子进程隔离不改变这一点,因为被链接的是 SDK 代码本身。
|
||||
|
||||
### 随包分发的第三方组件
|
||||
|
||||
| 组件 | 许可 | 位置 |
|
||||
|---|---|---|
|
||||
| Chinese-CLIP ViT-B/16(ONNX 产物) | Apache-2.0 | `/usr/lib/homeagent/models/chinese-clip-vit-b16-onnx/` |
|
||||
| ONNX Runtime(`libonnxruntime.so`) | MIT | `/usr/lib/homeagent/onnxruntime/` |
|
||||
| jieba 词库(内嵌进二进制) | MIT | 源码 `internal/memory/jiebadict/` |
|
||||
| Go 依赖(go-sqlite3、gojieba、bubbletea 等) | MIT / BSD-3 / Apache-2.0 | 均为宽松许可,与 AGPL-3.0 兼容 |
|
||||
|
||||
这些组件**保持各自原有许可**,不在本项目的 AGPL 授权范围内;发行包把它们的许可全文放在
|
||||
`/usr/share/doc/homeagent/licenses/`,并在 dep/rpm 元数据里声明本包许可为 `AGPL-3.0-only`。
|
||||
|
||||
67
README_EN.md
67
README_EN.md
@ -179,11 +179,45 @@ internal/
|
||||
├── config/ SQLite config center
|
||||
├── events/ Event bus
|
||||
└── internal/lua/adapters/ 8 LLM protocol adapter scripts
|
||||
External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) repo, use `plugindev` toolchain, refer to Go and Lua examples in `example/`
|
||||
External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) repo, use `hmapdev` toolchain, refer to Go and Lua examples in `example/`
|
||||
```
|
||||
|
||||
## Project Status
|
||||
|
||||
**v1.2.0** — unified multimodal vector space, media promoted to first-class graph memory, and the whole data plane moved into shared memory.
|
||||
|
||||
- **Model-neutral unified embedding space**: the kernel no longer adapts to any specific model.
|
||||
It exposes only a public provider SPI (`pkg/embedding`: `Modality` / `Input{Data,MIME}` /
|
||||
`Info{Dimension,Fingerprint,Modalities}` + a name registry), with implementations under
|
||||
`providers/*`. Default: **Chinese-CLIP ViT-B/16** — text and image land in the **same space**
|
||||
(512-dim, fingerprint `cd2a495cf990`, Apache-2.0, ~1.15GB RSS measured standalone);
|
||||
`qwen3vl` is kept (2048-dim, ~9.4GB) for machines with headroom or future video. Text search
|
||||
still falls back to the existing word-vector / TF-IDF path — a CLIP dual tower's pure-text
|
||||
semantics are **weaker than an MLLM-style embedder**, a cost documented rather than hidden.
|
||||
- **Media are first-class nodes and edges in the graph DB**: the "index images via generated
|
||||
text descriptions" stopgap, `media_refs` and media reference counting are **removed**.
|
||||
Memory blocks follow a single-layer invariant — Context → Document → Graph is a **migration**,
|
||||
not a copy, and not kept alive by references.
|
||||
- **The entire data plane goes through shared memory** (tool-call frames, Cleaners, input/output
|
||||
lanes, media blocks, document and knowledge bodies); RPC carries only offset descriptors.
|
||||
**RPC protocol is now 2**: the fd3 layout changed and there is **no rolling upgrade** —
|
||||
kernel and all plugins must be rebuilt and installed together.
|
||||
- Injections can declare `InjectOptions{NoMemory, ContextPolicy}` (**defaults: still recorded,
|
||||
not pruned**); pruning must be requested explicitly and goes through the plugin's registered
|
||||
`Cleaner`. SDK 1.2.0 is **purely additive** over 1.1.0.
|
||||
- **Release packages enable the ONNX space by default** and bundle the model (754MB) plus
|
||||
ONNX Runtime (24MB) in the server/full packages; `homed` drops native Windows support in
|
||||
favour of WSL2; the jieba dictionary is embedded in the binary.
|
||||
- Fixed three **silent install-chain failures**: `initconfig` was a no-op (`CGO_ENABLED=0` stub)
|
||||
that printed credentials without writing any, fresh installs were misdetected as "already
|
||||
configured" so default seeding was skipped entirely (0 plugins installed), and the deb
|
||||
`postinst` looked for the unit in the wrong path so `enable` never ran.
|
||||
- Licensed **AGPL-3.0-only** from this version on (network clause included; statically linked
|
||||
plugins must match — see License).
|
||||
|
||||
> The historical entries below are kept verbatim to show the evolution; two mechanisms in them
|
||||
> were **removed in v1.2.0**: text-description-based media indexing, and reference-counted media GC.
|
||||
|
||||
**v1.1.1** — Multimodal reaches the **plugin boundary**. v1.1.0 gave the memory system binary
|
||||
multimedia nodes, but that path was open only to the kernel itself; this release opens it to
|
||||
plugins and the model. The public SDK gains media fields and three media injection methods
|
||||
@ -206,7 +240,7 @@ by `-race`; in production this showed up as sporadic nil-dereference crashes dur
|
||||
items are never deleted). The description text produced by the vision model is the durable
|
||||
semantic memory; the blob is only a cache that capacity GC may evict.
|
||||
|
||||
**v1.0.0** — External plugins moved from C ABI shared libraries to **subprocess + shared memory**. The first release that no longer loads `.so`/`.dll`, and it is incompatible with 0.9.x (existing plugins must be rebuilt into `plugin.bin` with the new `plugindev`, though **business code needs zero changes**). Eliminates 6 classes of defects that had caused production incidents: hot-reload silently failing (`DF_1_NODELETE` making `dlclose` a no-op), no crash isolation (a plugin panic took down homed), stage lost updates (35.8~36.8% loss under the copy model), uncancellable cgo timeouts (linear OS-thread leaks), `output_send` reporting false success (the model was told "sent" while the message never went out), and Windows capability degradation (only 3 stage fields visible, no write-back). Three communication planes: stdio JSON-RPC (control) + shared memory segment (data) + event ring (notification); the privilege gradient is now enforced by three explicit gates. RPC round-trip p50 24.1µs; crash-to-recovery under 1s.
|
||||
**v1.0.0** — External plugins moved from C ABI shared libraries to **subprocess + shared memory**. The first release that no longer loads `.so`/`.dll`, and it is incompatible with 0.9.x (existing plugins must be rebuilt into `plugin.bin` with the new toolchain — called `plugindev` back then, **now `hmapdev`** — though **business code needs zero changes**). Eliminates 6 classes of defects that had caused production incidents: hot-reload silently failing (`DF_1_NODELETE` making `dlclose` a no-op), no crash isolation (a plugin panic took down homed), stage lost updates (35.8~36.8% loss under the copy model), uncancellable cgo timeouts (linear OS-thread leaks), `output_send` reporting false success (the model was told "sent" while the message never went out), and Windows capability degradation (only 3 stage fields visible, no write-back). Three communication planes: stdio JSON-RPC (control) + shared memory segment (data) + event ring (notification); the privilege gradient is now enforced by three explicit gates. RPC round-trip p50 24.1µs; crash-to-recovery under 1s.
|
||||
|
||||
**v0.9.0** — C ABI v2: external plugin Stage callbacks can now write back (`invoke_stage` gained a result out-param; plugins may mutate RawMessage/LLMText/ToolResults etc. in OnInput/AfterToolcall/PostAction and have them synced to the core). ABI version now tracks core minor releases (v0.9.x → ABIVersion=2, `version_min=1` keeps old plugins loadable). Also fixes the tool-loop zen-compat placeholder that wrongly fired on first-turn system context tail. The SDK ships an enhanced sanitizer example (bad-UTF-8 / U+FFFD / ANSI-escape scrub across the whole pipeline). **This ABI retired with v1.0.0.**
|
||||
|
||||
@ -231,7 +265,7 @@ semantic memory; the blob is only a cache that capacity GC may evict.
|
||||
| **client** | waiter + desktop GUI | Connecting to a remote HomeAgent |
|
||||
|
||||
- Linux: `.deb` (amd64/arm64), `.rpm` (x86_64), `.tar.gz`
|
||||
- Windows: `HomeAgent_v1.1.1_{Full,Server,Client}_win64.exe` (NSIS installer)
|
||||
- Windows: `HomeAgent_v1.2.0_{Full,Server,Client}_win64.exe` (NSIS installer, includes the AGPL license page). Since v1.2.0 `homed` no longer supports native Windows (it relies on fd inheritance and in-segment offset dereferencing), so the installer bootstraps **WSL2** and installs the Linux packages inside the distribution the same way a Linux host would.
|
||||
- Portable: `homeagent-bin-<os>_<arch>.tar.gz` (homed/waiter/initconfig)
|
||||
- Verification: `SHA256SUMS`
|
||||
|
||||
@ -246,3 +280,30 @@ make install # Install to system
|
||||
```
|
||||
|
||||
Dependencies: Go 1.25+, CGo (go-sqlite3), Linux/Windows.
|
||||
|
||||
## License
|
||||
|
||||
This project is released under the **GNU Affero General Public License, version 3
|
||||
(AGPL-3.0-only)** — see [LICENSE](LICENSE).
|
||||
|
||||
This is the strongest copyleft in the GPL family: besides shipping the complete corresponding
|
||||
source when you distribute the software, **you must also offer the source to users who interact
|
||||
with it over a network** (§13, Remote Network Interaction). Anyone running a modified HomeAgent
|
||||
as a network service therefore has to make the modified source available to that service's users.
|
||||
|
||||
Plugins are **statically linked** against this project through the public SDK (the SDK source
|
||||
ends up inside the plugin binary), so plugins are derivative works and must be released under
|
||||
the same license. Process isolation does not change this — what is linked is the SDK code itself.
|
||||
|
||||
### Third-party components shipped with the packages
|
||||
|
||||
| Component | License | Location |
|
||||
|---|---|---|
|
||||
| Chinese-CLIP ViT-B/16 (ONNX artifacts) | Apache-2.0 | `/usr/lib/homeagent/models/chinese-clip-vit-b16-onnx/` |
|
||||
| ONNX Runtime (`libonnxruntime.so`) | MIT | `/usr/lib/homeagent/onnxruntime/` |
|
||||
| jieba dictionary (embedded in the binary) | MIT | `internal/memory/jiebadict/` |
|
||||
| Go dependencies (go-sqlite3, gojieba, bubbletea, …) | MIT / BSD-3 / Apache-2.0 | permissive, AGPL-3.0-compatible |
|
||||
|
||||
These components keep their own licenses and are not relicensed by this project. Full texts are
|
||||
shipped in `/usr/share/doc/homeagent/licenses/`, and the package metadata declares this package
|
||||
as `AGPL-3.0-only`.
|
||||
|
||||
@ -90,8 +90,8 @@ Setting `ctx.Response` at any stage jumps to `after_output`.
|
||||
RelevanceContext — In-memory events[] + JSON persistence
|
||||
Append: Each input, CleanTemplateText → three-branch vector(textForVector)
|
||||
agent→Response, user→Input, cold_storage→Input+Response
|
||||
StaticEmbedder pretrained word embedding / TF-IDF fallback
|
||||
Prune: StaticEmbedder CosineSimilarity, keep topK + last 10
|
||||
Vector layers: unified multimodal space (primary, with fingerprint) → StaticEmbedder word embedding → TF-IDF (fallback)
|
||||
Prune: DenseCosine (compared only within the same fingerprint) → StaticEmbedder CosineSimilarity fallback; keep topK + last 10
|
||||
├── Keep → timeline → chronologically sorted → system prompt
|
||||
└── Low score → Document layer archive (original timestamp)
|
||||
Save: 5s debounce write to disk
|
||||
@ -99,7 +99,7 @@ Setting `ctx.Response` at any stage jumps to `after_output`.
|
||||
↓ Prune archive ↑ LLM active recall
|
||||
|
||||
② Document (File Memory)
|
||||
DocStore — JSON files + shared StaticEmbedder vector space with Context (fallback: TF-IDF InvertedIndex)
|
||||
DocStore — JSON files + dense vectors (unified multimodal space; dense_fp must match the current space fingerprint or the doc is recomputed; fallback: StaticEmbedder / TF-IDF InvertedIndex)
|
||||
Write: Prune archive / doc_commit / Graph snapshot (syncGraphToDocs)
|
||||
Read:
|
||||
├── Auto-inject: Query(input, top3) → similarity summary under same vector space → [Related Memory Docs] → system prompt (read-only)
|
||||
@ -132,11 +132,22 @@ Setting `ctx.Response` at any stage jumps to `after_output`.
|
||||
→ triples → GraphDB.Commit
|
||||
```
|
||||
|
||||
### Vectorization: Pretrained Word Embedding + TF-IDF Fallback
|
||||
### Vectorization: Unified Multimodal Space (primary) → Word Embedding → TF-IDF (fallback)
|
||||
|
||||
All vectorization unified under `StaticEmbedder` (`internal/memory/static_embedder.go`):
|
||||
Vectorization degrades through three layers by availability; **each missing layer reports an explicit
|
||||
error and never pretends to succeed**:
|
||||
|
||||
**Primary Strategy — Pretrained Word Embedding (aligned 300d)**
|
||||
**① Unified multimodal space (primary path, since v1.2.0)**
|
||||
Text and images share **one model, one dimension, one fingerprint** (default `chineseclip`: 512d,
|
||||
Apache-2.0, Chinese-native; `qwen3vl` or an external `http` provider are alternatives).
|
||||
Providers register through the public `pkg/embedding` SPI — **the kernel hardcodes no model**.
|
||||
Vectors persist together with their fingerprint (`dense_fp` / `vec_model`); any mismatch with the
|
||||
current fingerprint triggers recomputation, and only blocks with the **same fingerprint and the same
|
||||
dimension** participate in fusion (mixing coordinate systems yields a direction resembling neither).
|
||||
|
||||
**② Word embedding (text fallback)** — `StaticEmbedder` (`internal/memory/static_embedder.go`):
|
||||
|
||||
**Model sources** (aligned 300d)
|
||||
- Model sources: ConceptNet Numberbatch (77-language aligned) / fastText Chinese / fastText English
|
||||
- Configured via `core.agent.embedding_model_path` (comma-separated multi-model)
|
||||
- Path containing `numberbatch` → auto-download ConceptNet; `cc.zh.` → fastText Chinese; `cc.en.` → fastText English
|
||||
@ -196,28 +207,32 @@ single typo silently breaks reference binding with no error anywhere in the chai
|
||||
also gained `sentence_text`: media references hang off a sentence, so with no sentence there is
|
||||
nowhere to attach them.
|
||||
|
||||
### Media Memory (since v1.1.0)
|
||||
### Media Memory (since v1.2.0: first-class memory blocks)
|
||||
|
||||
`internal/memory/media/` — `Store`, content-addressed (CAS)
|
||||
Media is not attached content but a **first-class memory node**: `internal/memory/media/` is a
|
||||
content-addressed store (CAS), and graph `block` nodes carry its digest plus its own vector, while
|
||||
structural edges (e.g. `sentence --contains--> block`) express ownership.
|
||||
|
||||
| Concern | Approach | Why |
|
||||
|---|---|---|
|
||||
| Addressing | sha256 digest; metadata in SQLite, blobs on disk | Identical bytes stored once; metadata must be queryable, blobs must not live in the database |
|
||||
| Addressing | sha256 digest; metadata in SQLite, blobs on disk (`blobs/<first2>/<rest>`, two-level fanout) | Identical bytes stored once; metadata must be queryable, blobs must not live in the database |
|
||||
| Integrity | Every `Get` re-verifies the digest | Silently returning corrupt data on disk damage is far worse than an error |
|
||||
| Write atomicity | `.tmp` + rename | A half-written file taken as complete content would permanently poison that digest |
|
||||
| References | `owner_kind/owner_id/digest` composite primary key, `AddRef` idempotent | Three owner kinds: `context` (context events), `document`, `graph_sentence` |
|
||||
| GC | Two-stage with `minAge`, **referenced items are never deleted** | Description text stays in the text layers while blobs may be evicted — semantic memory and byte cache are decoupled |
|
||||
| Retrieval | Blocks carry **their own multimodal vector and fingerprint** and are searched directly | No description text is needed as an intermediary |
|
||||
| Lifecycle | **No separate GC, no refcounts, no keep-set**; deleting the block deletes the content | Media is a memory node, not a cache that needs keeping alive |
|
||||
|
||||
**How media is represented in plain-text memory** is the marker `[<mime> <short digest>] <description>`:
|
||||
**Description-based indexing is gone**: the old implementation embedded a
|
||||
`[<mime> <short digest>] <description>` marker in the body and treated the description as the
|
||||
semantic memory (retrieval used it). That path was removed wholesale in v1.2.0: a description is
|
||||
second-hand model output, and retrieving "someone else's paraphrase of an image" is strictly worse
|
||||
than retrieving the image's own vector. Images are now retrieved only by their own vector in the
|
||||
unified space, and no media marker is written into the body.
|
||||
|
||||
```
|
||||
[image/png a1b2c3d4e5f6] a purple-blue-red three-band chart
|
||||
```
|
||||
|
||||
Why it must ride on text: `Doc.Content`, `sentences.text` and text memory's `Input` are all strings
|
||||
— there is no field to carry structured data. **The description text is the durable semantic
|
||||
memory** (retrieval uses it); the digest is the key back to the bytes (reverse lookup uses it).
|
||||
After capacity GC evicts a blob, the description remains in the L0/L2/L3 text.
|
||||
**Cross-space vector migration**: media rows store their vector together with `vec_model` (the space
|
||||
fingerprint). At startup `reembedStaleMedia()` recomputes and **writes back** every row whose
|
||||
`vec_model` is empty (never embedded) or differs from the current space (model/dimension switched).
|
||||
Modalities outside the space return `ErrModalityUnsupported` — the kernel **never substitutes
|
||||
another model's vector**.
|
||||
|
||||
The media store is **optional throughout**: with `core.memory.media.enabled=false` or no
|
||||
configuration, the whole chain silently degrades to plain-text behaviour — no errors, no panics.
|
||||
@ -292,7 +307,7 @@ VM built-ins: `json.encode` / `json.decode` / `log` / `http_get` / `http_post`.
|
||||
| Method | Registration Mechanism | Compilation | Usage |
|
||||
|--------|----------------------|-------------|-------|
|
||||
| Built-in | `init()` → `RegisterFactory` | `internal/plugins/` compiled into kernel | webui/cli/timer/mcp etc. |
|
||||
| External subprocess plugin | Handshake + stdio JSON-RPC reverse registration | `plugindev build` → `plugin.bin` (ordinary Go binary) | qq/browser/files etc. |
|
||||
| External subprocess plugin | Handshake + stdio JSON-RPC reverse registration | `hmapdev build` → `plugin.bin` (ordinary Go binary) | qq/browser/files etc. |
|
||||
| Lua script plugin | Execute `main.lua` to register tools | No compilation, takes effect after restart/reload | luademo etc. |
|
||||
| SKILL plugin | Parse `SKILL.md` | Markdown definition | Loaded via clawhubadapter |
|
||||
|
||||
@ -311,7 +326,7 @@ Lua script plugin loading: `internal/plugin/` → the gopher-lua interpreter exe
|
||||
| Dimension | Built-in Plugin | External Plugin |
|
||||
|-----------|----------------|-----------------|
|
||||
| Registration | `init()` calls `plugin.RegisterFactory(name, factory)` | Implements `NewPluginFactory(name, config) (sdk.Plugin, error)` entry function |
|
||||
| Compilation | Compiled into `homed` binary, no separate build | Compiled via `plugindev build` to `plugin.bin` (ordinary Go binary, zero cgo); the kernel spawns it as a subprocess |
|
||||
| Compilation | Compiled into `homed` binary, no separate build | Compiled via `hmapdev build` to `plugin.bin` (ordinary Go binary, zero cgo); the kernel spawns it as a subprocess |
|
||||
| Distribution | Bundled with kernel, not independently installable | `.hmap` package (ZIP archive), installed via WebUI or pluginmgr API |
|
||||
| Metadata | `plugin.RegisterPluginMeta()` for display name | `plugin.json` manifest file (name, version, entry, platforms, capabilities, etc.) |
|
||||
| Plugin directory | No separate directory, compiled into binary | `plugins/<name>/` independent directory with `plugin.json` + `plugin.bin` |
|
||||
|
||||
@ -29,75 +29,80 @@ type Plugin interface {
|
||||
|
||||
| Method | Use Case | Complexity |
|
||||
|--------|----------|------------|
|
||||
| **Subprocess plugin (recommended)** | Independently distributed third-party plugins | Medium, generated using `plugindev` toolchain |
|
||||
| **Subprocess plugin (recommended)** | Independently distributed third-party plugins | Medium, generated using `hmapdev` toolchain |
|
||||
| **Built-in plugin** | Released with HomeAgent | Simple, requires merging into main repo |
|
||||
| **Lua script plugin** | Lightweight rapid prototyping | Simple, generated using `plugindev init --lua` |
|
||||
| **Lua script plugin** | Lightweight rapid prototyping | Simple, generated using `hmapdev init --lua` |
|
||||
|
||||
---
|
||||
|
||||
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
|
||||
|
||||
## 1. Quick Start: Using the plugindev Toolchain
|
||||
## 1. Quick Start: Using the hmapdev Toolchain
|
||||
|
||||
`plugindev` is the unified plugin development toolchain provided in the SDK repository, supporting both Go and Lua plugin types.
|
||||
`hmapdev` is the unified plugin development toolchain provided in the SDK repository, supporting both Go and Lua
|
||||
plugin types, and producing `.hmap` plugin bundles (the tool is named after that package format).
|
||||
|
||||
> Rename note: as of 1.2.0 the toolchain was renamed from `plugindev` to `hmapdev`; the SDK store moved from
|
||||
> `~/.homeagent/plugindev/sdk` to `~/.homeagent/hmapdev/sdk` (the old directory keeps working automatically).
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
cd homeagent-sdk/tools/plugindev
|
||||
go build -o plugindev
|
||||
# Add plugindev to PATH or use directly
|
||||
cd homeagent-sdk/tools/hmapdev
|
||||
go build -o hmapdev
|
||||
# Add hmapdev to PATH or use directly
|
||||
# Prebuilt binaries also ship as release assets (hmapdev_linux_amd64, ...)
|
||||
```
|
||||
|
||||
### SDK Version Management
|
||||
|
||||
`plugindev sdk` manages local SDK versions:
|
||||
`hmapdev sdk` manages local SDK versions:
|
||||
|
||||
```bash
|
||||
plugindev sdk list # list installed SDK versions
|
||||
plugindev sdk current # show current SDK version
|
||||
plugindev sdk latest # show latest available version
|
||||
plugindev sdk install v0.8.0 # install a specific version
|
||||
plugindev sdk use v0.8.0 # switch to a version
|
||||
plugindev sdk path # show current SDK path
|
||||
hmapdev sdk list # list installed SDK versions
|
||||
hmapdev sdk current # show current SDK version
|
||||
hmapdev sdk latest # show latest available version
|
||||
hmapdev sdk install v1.2.0 # install a specific version
|
||||
hmapdev sdk use v1.2.0 # switch to a version
|
||||
hmapdev sdk path # show current SDK path
|
||||
```
|
||||
|
||||
SDK is stored at `~/.homeagent/plugindev/sdk/<version>/`; `plugindev init` reads the current SDK version for `go.mod`.
|
||||
SDK is stored at `~/.homeagent/hmapdev/sdk/<version>/`; `hmapdev init` reads the current SDK version for `go.mod`.
|
||||
|
||||
### Source Debugging
|
||||
|
||||
`plugindev debug` interprets plugin source and prints a call trace, no compilation environment needed:
|
||||
`hmapdev debug` interprets plugin source and prints a call trace, no compilation environment needed:
|
||||
|
||||
```bash
|
||||
plugindev debug [dir] # dir defaults to the current directory
|
||||
hmapdev debug [dir] # dir defaults to the current directory
|
||||
```
|
||||
|
||||
### Creating a Go Plugin
|
||||
|
||||
```bash
|
||||
plugindev init myplugin
|
||||
hmapdev init myplugin
|
||||
cd myplugin
|
||||
# Edit plugin code
|
||||
vim plugin.go
|
||||
# Build and package (default is a multi-platform bundle, see below)
|
||||
plugindev build
|
||||
hmapdev build
|
||||
# Output: dist/myplugin_bundle.hmap
|
||||
# Single-platform build:
|
||||
plugindev build --no-bundle
|
||||
hmapdev build --no-bundle
|
||||
# Output: dist/myplugin_linux_amd64.hmap (or windows_amd64)
|
||||
```
|
||||
|
||||
### Creating a Lua Plugin
|
||||
|
||||
```bash
|
||||
plugindev init myluaplugin --lua
|
||||
hmapdev init myluaplugin --lua
|
||||
cd myluaplugin
|
||||
# Edit plugin code
|
||||
vim main.lua
|
||||
# Local test
|
||||
lua main.lua
|
||||
# Build and package
|
||||
plugindev build
|
||||
hmapdev build
|
||||
# Output: dist/myluaplugin_lua.hmap
|
||||
```
|
||||
|
||||
@ -128,16 +133,16 @@ myluaplugin/
|
||||
|
||||
### Build & Package
|
||||
|
||||
`plugindev build` automatically handles compilation and packaging:
|
||||
`hmapdev build` automatically handles compilation and packaging:
|
||||
|
||||
```bash
|
||||
cd myplugin
|
||||
plugindev build # default bundle mode (multi-platform)
|
||||
plugindev build --no-bundle # single-target build (per plg.json targets)
|
||||
plugindev build --target linux/amd64 # append a target on top of plg.json targets
|
||||
plugindev build --outdir dist # output directory (default: dist)
|
||||
plugindev build --sdk-path <path> # SDK path override (go.mod replace)
|
||||
plugindev build --replace <mod@path> # append a go.mod replace directive (repeatable)
|
||||
hmapdev build # default bundle mode (multi-platform)
|
||||
hmapdev build --no-bundle # single-target build (per plg.json targets)
|
||||
hmapdev build --target linux/amd64 # append a target on top of plg.json targets
|
||||
hmapdev build --outdir dist # output directory (default: dist)
|
||||
hmapdev build --sdk-path <path> # SDK path override (go.mod replace)
|
||||
hmapdev build --replace <mod@path> # append a go.mod replace directive (repeatable)
|
||||
```
|
||||
|
||||
Execution process:
|
||||
@ -171,7 +176,7 @@ the kernel picks the one matching the current platform and renames it to `plugin
|
||||
> - `plugin.so` / `plugin.dylib` / `plugin.dll` are **no longer loaded**. The new kernel
|
||||
> skips legacy artifacts with an actionable error instead of crashing.
|
||||
> - **Business code needs no changes** — the public SDK interface is unchanged; just
|
||||
> rebuild with the new `plugindev`.
|
||||
> rebuild with the new `hmapdev` (formerly `plugindev`).
|
||||
> - The `entry` field in `plg.json` is **meaningless for Go plugins** now (leaving
|
||||
> `plugin.so` there is harmless); it only distinguishes Lua plugins.
|
||||
> - Artifacts no longer need cgo, so cross-compiling requires no target C toolchain.
|
||||
@ -180,12 +185,12 @@ the kernel picks the one matching the current platform and renames it to `plugin
|
||||
|
||||
### Build Targets & Multi-platform Bundle
|
||||
|
||||
**`plugindev build` defaults to bundle mode** (unless `plg.json` explicitly sets `"bundle": false`): it builds linux/amd64 + darwin/amd64 + windows/amd64 in one pass, producing a single `.hmap` with all platform binaries. The output manifest includes a `platforms` field. The kernel auto-selects the correct binary during installation.
|
||||
**`hmapdev build` defaults to bundle mode** (unless `plg.json` explicitly sets `"bundle": false`): it builds linux/amd64 + darwin/amd64 + windows/amd64 in one pass, producing a single `.hmap` with all platform binaries. The output manifest includes a `platforms` field. The kernel auto-selects the correct binary during installation.
|
||||
|
||||
```bash
|
||||
plugindev build # default bundle, outputs dist/myplugin_bundle.hmap
|
||||
plugindev build --bundle # explicitly enable bundle (same as above)
|
||||
plugindev build --no-bundle # disable bundle, build per plg.json targets
|
||||
hmapdev build # default bundle, outputs dist/myplugin_bundle.hmap
|
||||
hmapdev build --bundle # explicitly enable bundle (same as above)
|
||||
hmapdev build --no-bundle # disable bundle, build per plg.json targets
|
||||
```
|
||||
|
||||
Notes:
|
||||
@ -275,7 +280,7 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
|
||||
|
||||
### Entry Point
|
||||
|
||||
`plugindev init` generates `plugin.go` with the `NewPlugin` export function directly,
|
||||
`hmapdev init` generates `plugin.go` with the `NewPlugin` export function directly,
|
||||
which is the entry point when the kernel loads the plugin:
|
||||
|
||||
```go
|
||||
@ -284,7 +289,7 @@ func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
}
|
||||
```
|
||||
|
||||
At build time, `plugindev build` auto-generates subprocess runtime code
|
||||
At build time, `hmapdev build` auto-generates subprocess runtime code
|
||||
(`z_proc_gen.go` for the platform-independent part, plus `z_proc_shm_unix.go` /
|
||||
`z_proc_shm_windows.go`). All three platforms share the same entry point and the same
|
||||
RPC logic; only the cross-process resource-passing mechanism differs (inherited fds on
|
||||
|
||||
@ -90,8 +90,8 @@ eventLoop() → processTextInput()
|
||||
RelevanceContext — 内存 events[] + JSON持久化
|
||||
Append: 每次输入, CleanText → 三分支向量(textForVector)
|
||||
agent事件→Response, 用户事件→Input, cold_storage→Input+Response
|
||||
StaticEmbedder 预训练词嵌入 / TF-IDF 回退
|
||||
Prune: StaticEmbedder CosineSimilarity, 保留 topK + 最近10条
|
||||
向量层级:统一多模态空间(主,带 fingerprint)→ StaticEmbedder 词嵌入 → TF-IDF(回退)
|
||||
Prune: DenseCosine(仅同指纹才比较)→ 退化 StaticEmbedder CosineSimilarity;保留 topK + 最近10条
|
||||
├── 保留 → timeline → 按时间排序 → system prompt
|
||||
└── 低分 → Document 层归档 (原始时间戳)
|
||||
Save: 5s debounce 写盘
|
||||
@ -99,7 +99,7 @@ eventLoop() → processTextInput()
|
||||
↓ Prune 归档 ↑ LLM 主动召回
|
||||
|
||||
② Document (文件记忆)
|
||||
DocStore — JSON文件 + 与 Context 共享的 StaticEmbedder 向量空间(兜底: TF-IDF InvertedIndex)
|
||||
DocStore — JSON文件 + 稠密向量(统一多模态空间;dense_fp 须与当前空间同指纹,不符即重算;兜底: StaticEmbedder / TF-IDF InvertedIndex)
|
||||
写入: Prune归档 / doc_commit / Graph快照(syncGraphToDocs)
|
||||
读取:
|
||||
├── 自动注入: Query(input, top3) → 同一向量空间下相似度摘要 → 【相关记忆文档】→ system prompt (只读)
|
||||
@ -132,11 +132,20 @@ eventLoop() → processTextInput()
|
||||
→ 三元组 → GraphDB.Commit
|
||||
```
|
||||
|
||||
### 向量化:预训练词嵌入 + TF-IDF 回退
|
||||
### 向量化:统一多模态空间(主)→ 词嵌入 → TF-IDF(回退)
|
||||
|
||||
所有向量化统一使用 `StaticEmbedder`(`internal/memory/static_embedder.go`):
|
||||
向量化按可用性分三层降级,**每一层缺位都明确报错,不静默假装成功**:
|
||||
|
||||
**主策略 — 预训练词嵌入(词对齐 300 维)**
|
||||
**① 统一多模态空间(主路径,v1.2.0 起)**
|
||||
文本与图像共用**同一模型、同一维度、同一指纹**(默认 `chineseclip`:512 维、Apache-2.0、中文原生;
|
||||
亦可选 `qwen3vl` 或外部 `http` provider)。provider 经 `pkg/embedding` 公共 SPI 注册,
|
||||
**内核不硬编码任何模型**。向量与指纹一起持久化(`dense_fp` / `vec_model`),
|
||||
与当前指纹不一致即触发重算;融合时只接受**同指纹且同维度**的块向量
|
||||
(跨坐标系的向量混进去会算出两边都不像的方向)。
|
||||
|
||||
**② 词嵌入(文本兜底)** — `StaticEmbedder`(`internal/memory/static_embedder.go`):
|
||||
|
||||
**模型来源**(词对齐 300 维)
|
||||
- 模型来源:ConceptNet Numberbatch(77 语对齐)/ fastText 中文 / fastText 英文
|
||||
- 通过 `core.agent.embedding_model_path` 配置(逗号分隔多模型)
|
||||
- 路径名含 `numberbatch` → 自动下载 ConceptNet,含 `cc.zh.` → fastText 中文,含 `cc.en.` → fastText 英文
|
||||
@ -194,30 +203,31 @@ eventLoop() → processTextInput()
|
||||
要求调用方知道格式,等于让一个拼写错误静默切断引用绑定而全链路无人报错。
|
||||
`memory_commit` 同时新增 `sentence_text`:媒体引用挂在句子上,没有句子就无处可挂。
|
||||
|
||||
### 媒体记忆(v1.1.0 起)
|
||||
### 媒体记忆(v1.2.0 起:一等记忆块)
|
||||
|
||||
`internal/memory/media/` — `Store`,内容寻址(CAS)
|
||||
媒体不是外挂内容,而是**记忆的一等节点**:`internal/memory/media/` 是内容寻址仓储(CAS),
|
||||
图数据库里的 block 节点携带它的 digest 与向量,结构边(如 `sentence --contains--> block`)表达归属。
|
||||
|
||||
| 关注点 | 做法 | 为何 |
|
||||
|---|---|---|
|
||||
| 寻址 | sha256 digest,元数据在 SQLite、blob 在磁盘 | 相同字节只存一份;元数据要可查询,blob 不该进数据库 |
|
||||
| 寻址 | sha256 digest;元数据在 SQLite,blob 在磁盘(`blobs/<前2位>/<其余>` 两级分桶) | 相同字节只存一份;元数据要可查询,blob 不该进数据库 |
|
||||
| 完整性 | 每次 `Get` 重校 digest | 磁盘损坏时静默返回脏数据比报错危险得多 |
|
||||
| 写入原子性 | `.tmp` + rename | 半个文件被当成完整内容会永久污染那个 digest |
|
||||
| 引用 | `owner_kind/owner_id/digest` 三元组主键,`AddRef` 幂等 | 三个 owner 类型:`context`(上下文事件)、`document`(文档)、`graph_sentence`(图谱句子) |
|
||||
| GC | 两阶段 + `minAge`,**有引用者绝不删** | 描述文本留在文本层,blob 可淘汰——语义记忆与字节缓存分离 |
|
||||
| 检索 | 块携带**自己的多模态向量与指纹**,直接参与向量检索 | 不需要描述文本做中介 |
|
||||
| 生命周期 | **无独立 GC、无引用计数、无 keep-set**;删除块即删内容 | 媒体是记忆节点,不是需要保活的缓存 |
|
||||
|
||||
**媒体在纯文本记忆里的表示**是标记 `[<mime> <短digest>] <描述>`:
|
||||
**不再有描述式索引**:旧实现在正文里写 `[<mime> <短digest>] <描述>` 标记,并把描述文本当作语义记忆
|
||||
(检索靠描述)。该机制已在 v1.2.0 整体拆除:描述是模型生成的二手信息,
|
||||
检索“别人转述的图片”不如检索图片自己的向量。现在图片只按自己的统一空间向量被检索,
|
||||
正文里不再有 media marker。
|
||||
|
||||
```
|
||||
[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图
|
||||
```
|
||||
**跨空间向量迁移**:媒体行的向量带 `vec_model`(空间指纹)。启动时
|
||||
`reembedStaleMedia()` 把 `vec_model` 为空(从未嵌入)或与当前空间不一致(换过模型/维度)的行
|
||||
批量重算并**写回库**;模态不在本空间覆盖范围时返回 `ErrModalityUnsupported`,
|
||||
**绝不拿别的模型的向量顶替**。
|
||||
|
||||
之所以必须借文本承载:`Doc.Content`、`sentences.text`、文本记忆的 `Input` 全是字符串,
|
||||
没有字段能挂结构化数据。**描述文本才是持久的语义记忆**(检索靠它),digest 是回到字节的
|
||||
钥匙(反查靠它)。blob 被容量 GC 淘汰后,描述仍留在 L0/L2/L3 的文本里。
|
||||
|
||||
媒体存储**全程可选**:`core.memory.media.enabled=false` 或未配置时,整条链路静默退化为
|
||||
纯文本行为,不报错不 panic。
|
||||
媒体存储全程可选:`core.memory.media.enabled=false` 或未配置时,整条链路静默退化为纯文本行为,
|
||||
不报错不 panic。
|
||||
|
||||
### 其他记忆层
|
||||
|
||||
@ -287,7 +297,7 @@ VM 内置 `json.encode` / `json.decode` / `log` / `http_get` / `http_post`。
|
||||
| 方式 | 注册机制 | 编译 | 用途 |
|
||||
|------|----------|------|------|
|
||||
| 内置插件 | `init()` → `RegisterFactory` | `internal/plugins/` 编译进内核 | webui/cli/timer/mcp 等 |
|
||||
| 外部子进程插件 | 握手 + stdio JSON-RPC 反向注册 | `plugindev build` → `plugin.bin`(普通 Go 二进制) | qq/browser/files 等 |
|
||||
| 外部子进程插件 | 握手 + stdio JSON-RPC 反向注册 | `hmapdev build` → `plugin.bin`(普通 Go 二进制) | qq/browser/files 等 |
|
||||
| Lua 脚本插件 | 执行 `main.lua` 注册工具 | 无需编译,重启/重载生效 | luademo 等 |
|
||||
| SKILL 插件 | 解析 `SKILL.md` | Markdown 定义 | clawhubadapter 兼容加载 |
|
||||
|
||||
@ -306,7 +316,7 @@ Lua 脚本插件加载:`internal/plugin/` → gopher-lua 解释器执行 `main
|
||||
| 维度 | 内置插件 | 外部插件 |
|
||||
|------|----------|----------|
|
||||
| 注册方式 | `init()` 调用 `plugin.RegisterFactory(name, factory)` | 实现 `NewPluginFactory(name, config) (sdk.Plugin, error)` 入口函数 |
|
||||
| 编译方式 | 编译进 `homed` 二进制,无需独立编译 | 通过 `plugindev build` 编译为 `plugin.bin`(普通 Go 二进制,零 cgo),内核 spawn 为子进程 |
|
||||
| 编译方式 | 编译进 `homed` 二进制,无需独立编译 | 通过 `hmapdev build` 编译为 `plugin.bin`(普通 Go 二进制,零 cgo),内核 spawn 为子进程 |
|
||||
| 分发方式 | 随内核分发,不可独立安装/卸载 | `.hmap` 包(ZIP 归档),通过 WebUI 或 pluginmgr API 安装 |
|
||||
| 元数据 | 通过 `plugin.RegisterPluginMeta()` 注册显示名 | `plugin.json` manifest 文件(name, version, entry, platforms, capabilities 等) |
|
||||
| 插件目录 | 无独立目录,编译进二进制 | `plugins/<name>/` 独立目录,包含 `plugin.json` + `plugin.bin` |
|
||||
|
||||
@ -30,75 +30,80 @@ type Plugin interface {
|
||||
|
||||
| 方式 | 适用场景 | 复杂度 |
|
||||
|------|---------|--------|
|
||||
| **子进程插件(推荐)** | 独立分发的第三方插件 | 中等,使用 `plugindev` 工具链生成 |
|
||||
| **子进程插件(推荐)** | 独立分发的第三方插件 | 中等,使用 `hmapdev` 工具链生成 |
|
||||
| **内置插件** | 随 HomeAgent 一起发布 | 简单,需合入主仓库 |
|
||||
| **Lua 脚本插件** | 轻量快速原型 | 简单,使用 `plugindev init --lua` 生成 |
|
||||
| **Lua 脚本插件** | 轻量快速原型 | 简单,使用 `hmapdev init --lua` 生成 |
|
||||
|
||||
---
|
||||
|
||||
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
|
||||
|
||||
## 一、快速开始:使用 plugindev 工具链
|
||||
## 一、快速开始:使用 hmapdev 工具链
|
||||
|
||||
`plugindev` 是 SDK 仓库提供的统一插件开发工具链,支持 Go 和 Lua 两种插件类型。
|
||||
`hmapdev` 是 SDK 仓库提供的统一插件开发工具链,支持 Go 和 Lua 两种插件类型,
|
||||
最终产出 `.hmap` 插件包(工具名即来自这个包格式)。
|
||||
|
||||
> 改名说明:1.2.0 起工具链由 `plugindev` 更名为 `hmapdev`;SDK 存储目录同时由
|
||||
> `~/.homeagent/plugindev/sdk` 迁到 `~/.homeagent/hmapdev/sdk`(旧目录会自动继续沿用)。
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
cd homeagent-sdk/tools/plugindev
|
||||
go build -o plugindev
|
||||
# 将 plugindev 加入 PATH 或直接使用
|
||||
cd homeagent-sdk/tools/hmapdev
|
||||
go build -o hmapdev
|
||||
# 将 hmapdev 加入 PATH 或直接使用
|
||||
# 也可从 SDK 的 release 附件下载预编译二进制(hmapdev_linux_amd64 等)
|
||||
```
|
||||
|
||||
### SDK 版本管理
|
||||
|
||||
`plugindev sdk` 子命令管理本地 SDK 版本:
|
||||
`hmapdev sdk` 子命令管理本地 SDK 版本:
|
||||
|
||||
```bash
|
||||
plugindev sdk list # 列出已安装的 SDK 版本
|
||||
plugindev sdk current # 显示当前使用的 SDK 版本
|
||||
plugindev sdk latest # 显示最新可用版本
|
||||
plugindev sdk install v0.8.0 # 安装指定版本
|
||||
plugindev sdk use v0.8.0 # 切换使用版本
|
||||
plugindev sdk path # 显示当前 SDK 路径
|
||||
hmapdev sdk list # 列出已安装的 SDK 版本
|
||||
hmapdev sdk current # 显示当前使用的 SDK 版本
|
||||
hmapdev sdk latest # 显示最新可用版本
|
||||
hmapdev sdk install v1.2.0 # 安装指定版本
|
||||
hmapdev sdk use v1.2.0 # 切换使用版本
|
||||
hmapdev sdk path # 显示当前 SDK 路径
|
||||
```
|
||||
|
||||
SDK 存储在 `~/.homeagent/plugindev/sdk/<version>/`,`plugindev init` 自动读取当前 SDK 版本填充 `go.mod`。
|
||||
SDK 存储在 `~/.homeagent/hmapdev/sdk/<version>/`,`hmapdev init` 自动读取当前 SDK 版本填充 `go.mod`。
|
||||
|
||||
### 源码调试
|
||||
|
||||
`plugindev debug` 直接用解释器执行插件源码并输出调用轨迹,无需编译环境:
|
||||
`hmapdev debug` 直接用解释器执行插件源码并输出调用轨迹,无需编译环境:
|
||||
|
||||
```bash
|
||||
plugindev debug [dir] # dir 默认当前目录
|
||||
hmapdev debug [dir] # dir 默认当前目录
|
||||
```
|
||||
|
||||
### 创建 Go 插件
|
||||
|
||||
```bash
|
||||
plugindev init myplugin
|
||||
hmapdev init myplugin
|
||||
cd myplugin
|
||||
# 编辑插件代码
|
||||
vim plugin.go
|
||||
# 编译打包
|
||||
plugindev build # 默认多平台 bundle(见下节)
|
||||
hmapdev build # 默认多平台 bundle(见下节)
|
||||
# 输出: dist/myplugin_bundle.hmap
|
||||
# 单平台构建:
|
||||
plugindev build --no-bundle
|
||||
hmapdev build --no-bundle
|
||||
# 输出: dist/myplugin_linux_amd64.hmap (或 windows_amd64)
|
||||
```
|
||||
|
||||
### 创建 Lua 插件
|
||||
|
||||
```bash
|
||||
plugindev init myluaplugin --lua
|
||||
hmapdev init myluaplugin --lua
|
||||
cd myluaplugin
|
||||
# 编辑插件代码
|
||||
vim main.lua
|
||||
# 本地测试
|
||||
lua main.lua
|
||||
# 编译打包
|
||||
plugindev build
|
||||
hmapdev build
|
||||
# 输出: dist/myluaplugin_lua.hmap
|
||||
```
|
||||
|
||||
@ -129,16 +134,16 @@ myluaplugin/
|
||||
|
||||
### 编译打包
|
||||
|
||||
`plugindev build` 会自动完成编译和打包:
|
||||
`hmapdev build` 会自动完成编译和打包:
|
||||
|
||||
```bash
|
||||
cd myplugin
|
||||
plugindev build # 默认 bundle 模式(多平台合集)
|
||||
plugindev build --no-bundle # 单平台构建(仅当前 plg.json targets)
|
||||
plugindev build --target linux/amd64 # 在 targets 基础上追加一个目标
|
||||
plugindev build --outdir dist # 指定输出目录(默认 dist)
|
||||
plugindev build --sdk-path <path> # 指定 SDK 路径(覆盖 go.mod replace)
|
||||
plugindev build --replace <mod@path> # 追加 go.mod replace 指令(可多次)
|
||||
hmapdev build # 默认 bundle 模式(多平台合集)
|
||||
hmapdev build --no-bundle # 单平台构建(仅当前 plg.json targets)
|
||||
hmapdev build --target linux/amd64 # 在 targets 基础上追加一个目标
|
||||
hmapdev build --outdir dist # 指定输出目录(默认 dist)
|
||||
hmapdev build --sdk-path <path> # 指定 SDK 路径(覆盖 go.mod replace)
|
||||
hmapdev build --replace <mod@path> # 追加 go.mod replace 指令(可多次)
|
||||
```
|
||||
|
||||
执行过程:
|
||||
@ -154,7 +159,7 @@ plugindev build --replace <mod@path> # 追加 go.mod replace 指令(可多次
|
||||
| 文件 | 用途 | 关键字段 |
|
||||
|------|------|---------|
|
||||
| `plg.json` | 项目元信息,由开发者维护 | `targets` — 单平台构建目标(如 `"linux/amd64,windows/amd64"`);`bundle` — 多平台合集开关(默认 `true`)|
|
||||
| `plugin.json` | 构建产物清单,`plugindev build` 自动生成 | `entry` — 入口文件名;`platforms` — 声明的支持平台 |
|
||||
| `plugin.json` | 构建产物清单,`hmapdev build` 自动生成 | `entry` — 入口文件名;`platforms` — 声明的支持平台 |
|
||||
|
||||
每个目标生成单独的 `.hmap`。子进程插件是普通可执行文件,**不分平台后缀**:
|
||||
|
||||
@ -169,7 +174,7 @@ bundle 包内按 `plugin.bin.<goos>.<goarch>` 区分各平台,安装时内核
|
||||
>
|
||||
> - `plugin.so` / `plugin.dylib` / `plugin.dll` **不再被加载**。新内核遇到旧产物
|
||||
> 会跳过并报可操作错误,不崩溃。
|
||||
> - **业务代码不需要改一行**——公开 SDK 接口零改动,只需用新版 `plugindev` 重编。
|
||||
> - **业务代码不需要改一行**——公开 SDK 接口零改动,只需用新版 `hmapdev`(原 `plugindev`)重编。
|
||||
> - `plg.json` 的 `entry` 字段对 Go 插件**已无意义**(写着 `plugin.so` 也无妨),
|
||||
> 它现在只用于区分 Lua 插件。
|
||||
> - 产物不再需要 cgo,交叉编译无需目标平台 C 工具链。
|
||||
@ -178,12 +183,12 @@ bundle 包内按 `plugin.bin.<goos>.<goarch>` 区分各平台,安装时内核
|
||||
|
||||
### 构建目标与多平台打包(bundle)
|
||||
|
||||
**`plugindev build` 默认就是 bundle 模式**(`plg.json` 未显式写 `"bundle": false` 时):一次编译 linux/amd64 + darwin/amd64 + windows/amd64,生成包含所有平台二进制的单 `.hmap`,输出清单自动添加 `platforms` 字段。安装时核心自动选择当前平台的二进制,跳过其他平台。
|
||||
**`hmapdev build` 默认就是 bundle 模式**(`plg.json` 未显式写 `"bundle": false` 时):一次编译 linux/amd64 + darwin/amd64 + windows/amd64,生成包含所有平台二进制的单 `.hmap`,输出清单自动添加 `platforms` 字段。安装时核心自动选择当前平台的二进制,跳过其他平台。
|
||||
|
||||
```bash
|
||||
plugindev build # 默认 bundle,输出 dist/myplugin_bundle.hmap
|
||||
plugindev build --bundle # 显式开启 bundle(同上)
|
||||
plugindev build --no-bundle # 关闭 bundle,按 plg.json 的 targets 逐平台构建
|
||||
hmapdev build # 默认 bundle,输出 dist/myplugin_bundle.hmap
|
||||
hmapdev build --bundle # 显式开启 bundle(同上)
|
||||
hmapdev build --no-bundle # 关闭 bundle,按 plg.json 的 targets 逐平台构建
|
||||
```
|
||||
|
||||
注意:
|
||||
@ -273,7 +278,7 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
|
||||
|
||||
### 入口点
|
||||
|
||||
`plugindev init` 生成的 `plugin.go` 中直接包含 `NewPlugin` 导出函数,它是内核加载插件时的入口:
|
||||
`hmapdev init` 生成的 `plugin.go` 中直接包含 `NewPlugin` 导出函数,它是内核加载插件时的入口:
|
||||
|
||||
```go
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
@ -281,7 +286,7 @@ func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
}
|
||||
```
|
||||
|
||||
编译时 `plugindev build` 自动生成子进程运行时代码(`z_proc_gen.go` 平台无关 + `z_proc_shm_unix.go` / `z_proc_shm_windows.go` 平台特定),无需手动编写。三平台共享同一入口与同一套 RPC 逻辑,仅跨进程资源传递机制不同(Unix 继承 fd,Windows 命名内核对象)。
|
||||
编译时 `hmapdev build` 自动生成子进程运行时代码(`z_proc_gen.go` 平台无关 + `z_proc_shm_unix.go` / `z_proc_shm_windows.go` 平台特定),无需手动编写。三平台共享同一入口与同一套 RPC 逻辑,仅跨进程资源传递机制不同(Unix 继承 fd,Windows 命名内核对象)。
|
||||
|
||||
### PluginSDK 核心 API
|
||||
|
||||
|
||||
@ -336,6 +336,10 @@ func main() {
|
||||
if err := docStore.Start(); err != nil {
|
||||
log.Printf("[homed] warning: document store: %v", err)
|
||||
}
|
||||
// 关停时落盘。文档记忆的内存态变更(迁移结果、访问计数等)只在 flush
|
||||
// 里写盘,而 flush 的唯一入口是 Stop()——此前全仓无人调用它,
|
||||
// 于是迁移结果永不落盘、每次启动白算一遍。
|
||||
defer docStore.Stop()
|
||||
|
||||
// 媒体存储(内容寻址):记忆块的内容后端。
|
||||
// 开关默认开;关闭后全部媒体接线静默跳过,对话行为与本特性上线前一致。
|
||||
@ -363,7 +367,11 @@ func main() {
|
||||
// 布局、预处理、解码、运行时全部属于 provider 内部实现。
|
||||
// provider 名为空时禁用多模态向量检索,退回纯 fastText 文本路径。
|
||||
var multimodalSpace vector.MultimodalEmbedder
|
||||
// 这两个值只用于状态报告(healthcheck_kernel 的 onnx 段):
|
||||
// 「配了哪个 provider」与「为什么没启用」,避免只能看到 false 却不知原因。
|
||||
var mmProviderName, mmErr string
|
||||
if mmProvider := cfgReg.GetString("core.memory.multimodal_space.provider", ""); mmProvider != "" {
|
||||
mmProviderName = mmProvider
|
||||
opts := map[string]string{}
|
||||
const optPrefix = "core.memory.multimodal_space.options."
|
||||
for _, key := range cfgReg.List("core.memory.multimodal_space.options.") {
|
||||
@ -371,10 +379,12 @@ func main() {
|
||||
}
|
||||
provider, err := embedding.Open(mmProvider, embedding.Config{Options: opts})
|
||||
if err != nil {
|
||||
mmErr = err.Error()
|
||||
log.Printf("[homed] warning: 多模态向量 provider %q 打开失败: %v(多模态向量检索已禁用;已注册: %s)",
|
||||
mmProvider, err, strings.Join(embedding.Names(), ", "))
|
||||
} else if adapted, err := vector.AdaptProvider(provider); err != nil {
|
||||
provider.Close()
|
||||
mmErr = err.Error()
|
||||
log.Printf("[homed] warning: 多模态向量 provider %q 元数据不合法: %v(多模态向量检索已禁用)", mmProvider, err)
|
||||
} else {
|
||||
multimodalSpace = adapted
|
||||
@ -401,13 +411,29 @@ func main() {
|
||||
// 人格设定
|
||||
// ========================================================================
|
||||
|
||||
// 人格来源优先级:personal/personal.md(高级覆盖,存在且非空才生效)
|
||||
// > 配置项 core.agent.personal_prompt(默认模板 = config.DefaultPersonaPrompt)。
|
||||
//
|
||||
// 曾经只有「文件」一个来源且无人维护,导致人格卡写死旧版本号与已删除的 C ABI、
|
||||
// 反过来让实例自称旧版本(v1.2.0 压测发现)。故:
|
||||
// - 配置项化 + 内置默认模板(不含版本号字面量)
|
||||
// - 文件仍在时生效,但扫到腐坏内容就在启动日志里明确告警
|
||||
personalPath := filepath.Join(cfg.Daemon.DataDir, "personal", "personal.md")
|
||||
personality, err := agentPkg.LoadPersonality(personalPath)
|
||||
if err != nil {
|
||||
log.Printf("[homed] warning: load personality: %v", err)
|
||||
}
|
||||
if personality != nil && personality.Content != "" {
|
||||
log.Printf("[homed] personality loaded (%d bytes)", len(personality.Content))
|
||||
log.Printf("[homed] 人格来源=文件 %s(优先于配置项),%d 字节", personalPath, len(personality.Content))
|
||||
if hints := agentPkg.PersonaStaleHints(personality.Content); len(hints) > 0 {
|
||||
log.Printf("[homed] warning: 人格文件含会腐坏的内容 %v — 建议迁到配置项 core.agent.personal_prompt"+
|
||||
"(默认模板不含版本号,被问版本时以运行时快照为准)", hints)
|
||||
}
|
||||
} else if pv := cfgReg.GetString("core.agent.personal_prompt", internalConfig.DefaultPersonaPrompt); strings.TrimSpace(pv) != "" {
|
||||
personality = &agentPkg.Personality{Content: pv, Path: "(core.agent.personal_prompt)"}
|
||||
log.Printf("[homed] 人格来源=配置项 core.agent.personal_prompt,%d 字节", len(pv))
|
||||
} else {
|
||||
log.Printf("[homed] 人格来源=无(配置项为空且无人格文件)")
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
@ -486,20 +512,23 @@ func main() {
|
||||
}
|
||||
|
||||
agent := agentCore.New(agentCore.AgentConfig{
|
||||
ID: "main",
|
||||
SystemPrompt: sysPrompt,
|
||||
Provider: provider,
|
||||
ProviderManager: providerMgr,
|
||||
IO: iom,
|
||||
Memory: memDB,
|
||||
Indexer: memIdx,
|
||||
Tracker: trk,
|
||||
DocStore: docStore,
|
||||
Knowledge: ks,
|
||||
SocialStore: socialStore,
|
||||
TextMemory: textMem,
|
||||
MediaStore: mediaStore,
|
||||
Personality: personality,
|
||||
ID: "main",
|
||||
SystemPrompt: sysPrompt,
|
||||
Provider: provider,
|
||||
ProviderManager: providerMgr,
|
||||
IO: iom,
|
||||
Memory: memDB,
|
||||
Indexer: memIdx,
|
||||
Tracker: trk,
|
||||
DocStore: docStore,
|
||||
Knowledge: ks,
|
||||
SocialStore: socialStore,
|
||||
TextMemory: textMem,
|
||||
MediaStore: mediaStore,
|
||||
Personality: personality,
|
||||
// 人格落库面:首启门禁(任何通道都问一次)与 persona_set 工具用。
|
||||
// 与 WebUI 向导共用 internal/config 的同一份落库逻辑。
|
||||
PersonaStore: internalConfig.RegistryPersonaStore{Reg: cfgReg},
|
||||
PluginReg: pluginReg,
|
||||
PluginDir: cfg.Plugin.Dir,
|
||||
DistillInterval: cfgReg.GetDuration("core.agent.distill_interval", 30*time.Minute),
|
||||
@ -510,6 +539,8 @@ func main() {
|
||||
EmbeddingModelPath: cfgReg.GetString("core.agent.embedding_model_path", ""),
|
||||
Embedder: embedder,
|
||||
MultimodalSpace: multimodalSpace,
|
||||
EmbeddingProvider: mmProviderName,
|
||||
EmbeddingError: mmErr,
|
||||
StageHost: stageHost,
|
||||
EventBus: evBus,
|
||||
ThinkingEnabled: cfg.LLM.ThinkingEnabled,
|
||||
|
||||
@ -24,7 +24,7 @@ import (
|
||||
// 所以选择:**原生 Windows 不提供 homed**。Windows 用户跑 WSL2——
|
||||
// WSL2 里就是普通 linux/amd64,走与我们测试矩阵完全相同的那条路径。
|
||||
//
|
||||
// 注意范围:只有 homed 如此。plugindev 工具链仍可在 Windows 上运行
|
||||
// 注意范围:只有 homed 如此。hmapdev 工具链仍可在 Windows 上运行
|
||||
// (在 Windows 上开发、为 WSL 构建 linux 插件是合理工作流)。
|
||||
func requireSupportedPlatform() {
|
||||
fmt.Fprintln(os.Stderr, "homed 不支持 Windows 原生运行。")
|
||||
|
||||
@ -15,6 +15,13 @@ BUILD_TIME="${BUILD_TIME:-$(date -u '+%Y-%m-%dT%H:%M:%SZ')}"
|
||||
GO="${GO:-$(command -v go 2>/dev/null || echo "go")}"
|
||||
LDFLAGS="-X gitcode.com/JianFeeeee/HomeAgent/internal/meta.Version=${VERSION} -X gitcode.com/JianFeeeee/HomeAgent/internal/meta.Commit=${COMMIT} -X gitcode.com/JianFeeeee/HomeAgent/internal/meta.BuildTime=${BUILD_TIME}"
|
||||
|
||||
# 版本与提交的**权威来源**是上面注入的 meta.Version / meta.Commit,不是 Go 自带的
|
||||
# VCS 戳。后者不进 build cache key(Go 文档明确说明 VCS 变化不会触发重建),
|
||||
# 命中缓存时会把上一次的 revision 一并带回来——实测发布分支的产物上就出现了
|
||||
# 1715b5c(本机任何仓库都不存在的提交),用 `go version -m` 溯源会指向幽灵提交。
|
||||
# 统一 -buildvcs=false:宁可没有这个信号,也不要一个错的。
|
||||
# 溯源请用:`strings homed | grep -m1 '^<短 hash>$'`(meta.Commit 是字符串常量)。
|
||||
|
||||
TARGET="${1:-native}"
|
||||
COMPONENT="${2:-all}"
|
||||
|
||||
@ -131,7 +138,7 @@ build_homed() {
|
||||
local _tags="${HOMED_TAGS-onnxruntime}"
|
||||
local -a _tagargs=()
|
||||
if [ -n "$_tags" ]; then _tagargs=(-tags "$_tags"); fi
|
||||
CGO_ENABLED=1 CC="$_cc" "$GO" build -trimpath -installsuffix dynlink \
|
||||
CGO_ENABLED=1 CC="$_cc" "$GO" build -buildvcs=false -trimpath -installsuffix dynlink \
|
||||
${_tagargs[@]+"${_tagargs[@]}"} \
|
||||
-ldflags "$LDFLAGS" -o "$out" ./cmd/homed/
|
||||
echo " OK ($(file "$out" | sed 's/.*: //') | $(du -h "$out" | cut -f1))"
|
||||
@ -144,7 +151,7 @@ build_waiter() {
|
||||
if [ "$GOOS" = "windows" ]; then out="${out}.exe"; fi
|
||||
|
||||
echo "[BUILD] waiter ${plat} → $out"
|
||||
CGO_ENABLED=0 "$GO" build -trimpath -installsuffix dynlink \
|
||||
CGO_ENABLED=0 "$GO" build -buildvcs=false -trimpath -installsuffix dynlink \
|
||||
-ldflags "$LDFLAGS" -o "$out" ./cmd/waiter/
|
||||
echo " OK ($(du -h "$out" | cut -f1))"
|
||||
}
|
||||
@ -165,7 +172,7 @@ build_initconfig() {
|
||||
if [ "$GOOS" = "windows" ]; then out="${out}.exe"; fi
|
||||
|
||||
echo "[BUILD] initconfig ${plat} → $out"
|
||||
CGO_ENABLED=1 "$GO" build -trimpath -installsuffix dynlink \
|
||||
CGO_ENABLED=1 "$GO" build -buildvcs=false -trimpath -installsuffix dynlink \
|
||||
-ldflags "$LDFLAGS" -o "$out" ./cmd/initconfig/
|
||||
echo " OK ($(du -h "$out" | cut -f1))"
|
||||
}
|
||||
@ -204,6 +211,14 @@ stage_linux_payload() {
|
||||
}
|
||||
|
||||
# ---- gui (Electron) ----
|
||||
#
|
||||
# 输出目录必须用 --config.directories.output,**不能用 -o**:
|
||||
# electron-builder 的 `-o` 是 `--mac`/`--macos` 的短别名(见 --help 的 Building 段),
|
||||
# 不是 output。此前 `-o "$BUILD_DIR"` 被当成 macOS 的 target 列表,报
|
||||
# ⨯ Unknown target: /home/program/trueagent/build
|
||||
# (路径被 lowercase 后去匹配 target 名表,所以错误信息里的路径是全小写的,
|
||||
# 这也是它看起来像「路径错」而实际是「参数位置错」的原因)。
|
||||
# v1.0.1 与 v1.0.3 两次发布都因此手工组装过 GUI。
|
||||
build_gui() {
|
||||
if [ -n "${GOOS:-}" ] && [ "$GOOS" != "$("$GO" env GOOS)" ]; then
|
||||
echo "[SKIP] gui ${GOOS}/${GOARCH} — electron-builder handles cross-platform natively; run 'all' on CI host"
|
||||
@ -221,12 +236,21 @@ build_gui() {
|
||||
# 不传 --config:electron-builder 默认从 package.json 的 "build" 键读配置。
|
||||
# 传 --config package.json 会让它把**整个** package.json 当配置校验,
|
||||
# 于是 devDependencies / build / scripts 全被判为 "unknown property" 而失败。
|
||||
(cd "$gui_dir" && npx electron-builder \
|
||||
--linux --win --mac \
|
||||
--x64 --arm64 \
|
||||
-p never \
|
||||
-o "$BUILD_DIR")
|
||||
echo " OK"
|
||||
#
|
||||
# GUI 失败不中断整体构建:homed/waiter/initconfig 是发布的主体,
|
||||
# 而 GUI 依赖 electron 运行时下载(离线机器、arm64 缺缓存都会失败)。
|
||||
# set -e 下若不接住,一个可选组件会让整轮跨平台构建全废。
|
||||
if (cd "$gui_dir" && npx electron-builder \
|
||||
--linux --win --mac \
|
||||
--x64 --arm64 \
|
||||
-p never \
|
||||
--config.directories.output="$BUILD_DIR"); then
|
||||
echo " OK"
|
||||
else
|
||||
echo " WARN: gui 构建失败(可选组件,不影响 homed/waiter/initconfig)"
|
||||
echo " Linux 包可用 deploy/packaging/package-linux.sh 内置的手工组装路径"
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- dispatch ----
|
||||
|
||||
@ -76,6 +76,10 @@ Function GenKey
|
||||
FunctionEnd
|
||||
|
||||
!insertmacro MUI_PAGE_WELCOME
|
||||
; 许可页:AGPL-3.0-only(全文在仓库根 LICENSE)。
|
||||
; NSIS 的 File/!insertmacro 相对路径以**本 .nsi 所在目录**为基准解析,
|
||||
; 而本文件在 deploy/packaging/,故仓库根是 ..\..\ 。
|
||||
!insertmacro MUI_PAGE_LICENSE "..\..\LICENSE"
|
||||
!insertmacro MUI_PAGE_DIRECTORY
|
||||
|
||||
!if "${HAS_CREDENTIALS}" == "1"
|
||||
|
||||
@ -335,6 +335,7 @@ stage_variant() {
|
||||
cp "$PROJECT_ROOT/deploy/homeagent.service" "$staging/etc/systemd/system/homeagent.service"
|
||||
[ -f "$initconfig_bin" ] && cp "$initconfig_bin" "$staging/usr/bin/initconfig"
|
||||
stage_setup "$staging"
|
||||
stage_license "$staging"
|
||||
stage_multimodal_assets "$staging"
|
||||
stage_gui "$staging"
|
||||
;;
|
||||
@ -344,10 +345,12 @@ stage_variant() {
|
||||
cp "$PROJECT_ROOT/deploy/homeagent.service" "$staging/etc/systemd/system/homeagent.service"
|
||||
[ -f "$initconfig_bin" ] && cp "$initconfig_bin" "$staging/usr/bin/initconfig"
|
||||
stage_setup "$staging"
|
||||
stage_license "$staging"
|
||||
stage_multimodal_assets "$staging"
|
||||
;;
|
||||
client)
|
||||
cp "$BUILD_DIR/waiter_$suffix" "$staging/usr/bin/waiter"
|
||||
stage_license "$staging"
|
||||
stage_gui "$staging"
|
||||
;;
|
||||
esac
|
||||
@ -382,6 +385,58 @@ stage_setup() {
|
||||
fi
|
||||
}
|
||||
|
||||
# 项目自身的许可:**所有变体**都要带(client 也分发 waiter 与 GUI)。
|
||||
#
|
||||
# deb 按 Debian 惯例给 /usr/share/doc/homeagent/copyright(DEP-5 机器可读格式),
|
||||
# 同时把 LICENSE 全文放进去;rpm 的许可走 fpm 的 --license 元数据。
|
||||
# 与 stage_multimodal_assets 的 licenses/ 分工:那里放**第三方**(模型/运行库)的
|
||||
# 许可全文,这里放本项目自己的。
|
||||
stage_license() {
|
||||
local staging="$1"
|
||||
local docdir="$staging/usr/share/doc/homeagent"
|
||||
mkdir -p "$docdir"
|
||||
cp "$PROJECT_ROOT/LICENSE" "$docdir/LICENSE"
|
||||
cat > "$docdir/copyright" <<'EOF'
|
||||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||
Upstream-Name: HomeAgent
|
||||
Source: https://gitcode.com/JianFeeeee/HomeAgent
|
||||
|
||||
Files: *
|
||||
Copyright: HomeAgent contributors
|
||||
License: AGPL-3.0-only
|
||||
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, version 3 of the License.
|
||||
.
|
||||
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/>.
|
||||
.
|
||||
The license is AGPL-3.0-only: no later version may be chosen. Note the network
|
||||
clause (§13 Remote Network Interaction) — offering modified versions of this
|
||||
software to users over a network also requires offering them the source.
|
||||
.
|
||||
Full text: /usr/share/doc/homeagent/LICENSE
|
||||
|
||||
Files: usr/lib/homeagent/models/chinese-clip-vit-b16-onnx/*
|
||||
Copyright: OFA-Sys / Chinese-CLIP authors
|
||||
License: Apache-2.0
|
||||
Full text: /usr/share/doc/homeagent/licenses/Chinese-CLIP-Apache-2.0.txt
|
||||
Comment: pre-trained model artifacts; NOT covered by this package's AGPL grant
|
||||
|
||||
Files: usr/lib/homeagent/onnxruntime/*
|
||||
Copyright: Microsoft Corporation
|
||||
License: MIT
|
||||
Full text: /usr/share/doc/homeagent/licenses/ONNX-Runtime-MIT.txt
|
||||
Comment: license texts and third-party notices under licenses/ONNX-Runtime-*
|
||||
EOF
|
||||
chmod 644 "$docdir/LICENSE" "$docdir/copyright"
|
||||
}
|
||||
|
||||
# server/full 的 ONNX 资产。模型与运行库是发行版能力的一部分,不是可选下载:
|
||||
# 只要打 server/full 包,两者缺一就失败。client 包不运行 homed,故不携带。
|
||||
stage_multimodal_assets() {
|
||||
@ -515,6 +570,7 @@ build_tar() {
|
||||
local setup_src="$PROJECT_ROOT/deploy/packaging/linux/setup.sh"
|
||||
[ -f "$setup_src" ] && cp "$setup_src" "$staging/usr/lib/homeagent/setup.sh"
|
||||
cp "$PROJECT_ROOT/deploy/homeagent.service" "$staging/etc/systemd/system/homeagent.service"
|
||||
stage_license "$staging"
|
||||
stage_multimodal_assets "$staging"
|
||||
|
||||
# GUI if available
|
||||
@ -593,7 +649,7 @@ build_rpm() {
|
||||
-a "$RPM_ARCH" \
|
||||
--description "HomeAgent ${variant^} package" \
|
||||
--url "https://github.com/trueagent/HomeAgent" \
|
||||
--license "Proprietary" \
|
||||
--license "AGPL-3.0-only" \
|
||||
-C "$staging" \
|
||||
-p "$rpm_dir/$pkg_name" \
|
||||
. 2>&1
|
||||
@ -654,18 +710,25 @@ main() {
|
||||
echo "=== Done! Packages in: $DIST_DIR ==="
|
||||
echo ""
|
||||
echo "Summary:"
|
||||
mapfile -t release_files < <(find "$DIST_DIR" -type f \( -name "*.deb" -o -name "homeagent_*.tar.gz" -o -name "*.rpm" \) 2>/dev/null | sort)
|
||||
# 只列**本批**产物:dist/ 会跨多次构建累积,用 find 全目录会让清单/SHA256SUMS
|
||||
# 带上历史版本的文件名——用户下载那种清单后 `sha256sum -c` 必然报缺失。
|
||||
# (v1.2.2 构建时就出现过:清单里混进了 1.2.0/1.2.1 的包名。)按本批版本号过滤。
|
||||
mapfile -t release_files < <(find "$DIST_DIR" -type f \( -name "*${PKG_VERSION}*.deb" -o -name "homeagent_${PKG_VERSION}_*.tar.gz" -o -name "*${PKG_VERSION}*.rpm" \) 2>/dev/null | sort)
|
||||
for f in "${release_files[@]}"; do
|
||||
echo " $(du -h "$f" | cut -f1) $f"
|
||||
done
|
||||
# 全部包生成之后一次计算,避免边打边算漏掉后生成的产物。
|
||||
# 名字用**平铺名**(basename):下载页的附件名就是平铺的,
|
||||
# 清单里若写 ./deb/xxx.deb,用户下载后 `sha256sum -c` 会找不到文件。
|
||||
if [ ${#release_files[@]} -gt 0 ]; then
|
||||
(
|
||||
cd "$DIST_DIR"
|
||||
find . -type f \( -name "*.deb" -o -name "homeagent_*.tar.gz" -o -name "*.rpm" \) \
|
||||
-print0 | sort -z | xargs -0 sha256sum > SHA256SUMS
|
||||
# 哈希取**真实路径**,标签用**平铺名**:两者不能混(直接对 basename 求哈希会找不到文件)。
|
||||
for f in "${release_files[@]}"; do
|
||||
printf '%s ./%s\n' "$(sha256sum "$f" | awk '{print $1}')" "$(basename "$f")"
|
||||
done | sort -k2 > SHA256SUMS
|
||||
)
|
||||
echo " SHA256SUMS: $DIST_DIR/SHA256SUMS"
|
||||
echo " SHA256SUMS: $DIST_DIR/SHA256SUMS (仅本批 ${#release_files[@]} 个产物,平铺名)"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
@ -130,26 +130,66 @@ main ──────────────── E ────────
|
||||
|
||||
---
|
||||
|
||||
## 三、当前分支对齐(2026-09-04 执行)
|
||||
### 7. 开发者文档的发布归属(以 rel 分支的形态为准)
|
||||
|
||||
**规则:面向使用者的开发者文档,先在对应的 `release/vX.Y.x` 上修正成「这一版的实际行为」,
|
||||
再 cherry-pick 合入 `main`。**(文档属 §二.3 所列的发布分支允许事项之一)
|
||||
|
||||
为什么不能直接改 main:
|
||||
|
||||
- `main` 的语义是**下一个未发布版本**(§二.1)。在那儿写的文档要么描述尚未发布的行为,
|
||||
要么与当前 rel 的实际行为**相反**,而文档的读者(包括模型自身)会把它当事实。
|
||||
- `assets/docs/**` 会**随发行包分发并在 WebUI 里被阅读**——它服务的是“这一版”,不是“下一版”。
|
||||
- 版本号、工具名、机制的有无都是**随版变动的**:同一个文件在两个分支上就应该是两种口径。
|
||||
|
||||
做法:
|
||||
|
||||
```bash
|
||||
git switch release/v1.2.x
|
||||
# 按这一版口径修改:版本号、当前工具名(hmapdev)、已移除机制不再写成现行
|
||||
# ... 编辑 assets/docs/**、README{,_EN}.md、docs/zh/** ...
|
||||
git commit -m "docs: 按 v1.2.x 口径修正 …"
|
||||
git switch main && git cherry-pick <sha> # 遵守 §三:只 pick,不 merge
|
||||
```
|
||||
|
||||
`main` 上若需要描述“下一版才有的行为”,必须显式标注(如「(下一版)」或附版本号),
|
||||
不得让读者以为它已发布。
|
||||
|
||||
**反例(本仓真实踩过,均为“文档当成事实后反向误导”)**:
|
||||
|
||||
| 现象 | 后果 |
|
||||
|---|---|
|
||||
| 人格卡写死 `v0.9.0(C ABI v2)` | 内核接口/日志报 1.2.0,agent 却向用户自述旧版本(且该机制 v1.0.0 已删除) |
|
||||
| 架构文档在 1.2.0 后仍把“描述式索引 + 引用计数 GC”写成现行机制 | 读者按已删除的设计理解现行行为 |
|
||||
| README 停在 v1.1.1 并描述已被删除的机制 | 同上 |
|
||||
|
||||
配套硬约束:**任何“模型或用户会当作事实”的文本,都不得写死版本号**——
|
||||
要么用 `meta.Version` 插值,要么要求读运行时快照,并用测试钉住
|
||||
(如 `TestDefaultPersonaPromptHasNoVersionLiterals`)。
|
||||
|
||||
---
|
||||
|
||||
## 三、当前分支对齐(2026-09-12 更新)
|
||||
|
||||
### 主仓(TrueAgent)
|
||||
|
||||
| 分支 | 状态 | 处理 |
|
||||
|---|---|---|
|
||||
| `main` | 含全部 hotfix(逐个 cherry-pick),`meta.Version` = 下一个未发布中版本(现为 `1.2.0`) | ✅ 保持 |
|
||||
| `release/v1.0.x` | 承载 v1.0.0 / v1.0.1 / v1.0.3 全部 tag | ✅ **由 `release/v1.0.1` 重命名而来**(2026-09-04) |
|
||||
| `release/v1.0.0` | `9b92a04`,已被 1.0.x 线完全包含(`merge-base --is-ancestor` 验证通过) | 🗑️ **已删除**(本地 + 远端),tag `v1.0.0` 保留全部历史 |
|
||||
| `release/v1.0.1` | 旧 patch 号命名 | 🗑️ **已重命名为 `release/v1.0.x`**(远端旧名删除) |
|
||||
| `feature/memory-media` | 记忆系统媒体(多模态)支持,进行中 | ⏳ 完成后合回 main 并删除 |
|
||||
| `feature/plugin-proc-migration` | 已合入 main(`525aa1f`) | ⏳ 待删(规范要求合回后删除) |
|
||||
| `release/v1.1.x` | 承载 v1.1.0 / v1.1.0-beta.1 / v1.1.1 全部 tag | ✅ 1.1 线的唯一发布分支 |
|
||||
| `main` | 含全部回流修复;`meta.Version` = 下一个未发布中版本(现为 `1.3.0`) | ✅ 保持 |
|
||||
| `release/v1.2.x` | **本条发布线**,`meta.Version` = `1.2.0`,vendored SDK 定版 `1.2.0`;已载入两个发布前修复(GUI 输出目录、知识库同名覆盖) | 🆕 2026-09-12 从 main 切出;**尚无 tag** |
|
||||
| `release/v1.1.x` | 承载 `v1.1.0-beta.1` / `v1.1.0` / `v1.1.1` | 📦 已退役(§2.6:下个中版本发布即退役),保留供追溯 |
|
||||
| `release/v1.0.x` | 承载 1.0.x 全部 tag | 📦 保留 |
|
||||
| `feature/multimodal-embedding` | 已合入 main(`eb4762a`,43 提交,`--no-ff`) | ⏳ 待删(删远端分支需用户确认,§执行守则 3) |
|
||||
|
||||
> `feature/memory-media`、`feature/plugin-proc-migration` 均已从远端删除(旧表里的待删项已处理)。
|
||||
|
||||
### SDK 仓(homeagent-sdk)
|
||||
|
||||
| 分支 | 状态 | 处理 |
|
||||
|---|---|---|
|
||||
| `main` | `meta.Version` = 下一个未发布中版本(现为 `1.2.0`) | ✅ 保持 |
|
||||
| `release/v1.1.x` | `meta.Version` = `1.1.0`,承载 tag `v1.1.0` | ✅ 与核心 `release/v1.1.x` 对应 |
|
||||
| `main` | `meta.Version` = 下一个未发布中版本(现为 **`1.2.0`**)——SDK **不跟 beta 发版**(§七.2),1.2.0 要等核心的**正式** tag 才定版(§七.3),在那之前路牌不得越过它。此阶段与核心 main(`1.3.0`)**故意不对称**,详见 §七.4 | ✅ 保持 |
|
||||
| `release/v1.1.x` | `meta.Version` = `1.1.0`,承载 tag `v1.1.0` | ✅ 与核心对应 |
|
||||
| `release/v1.2.x` | **尚未创建** | ⏳ 随核心**正式** tag 一起建(§七.3:分支上把版本定为 `1.2.0` 再打 `v1.2.0`;beta 阶段不发 SDK) |
|
||||
| `release/v1.0.0` | 旧 patch 号命名形态,内容已被 main 完全包含 | 📦 保留(供追溯 1.0 线构建) |
|
||||
|
||||
### 1.0.x 发布线 tag 历史
|
||||
@ -174,6 +214,18 @@ main ──────────────── E ────────
|
||||
> 而 semver 预发布语义里 `1.1.0-beta.1 < 1.1.0`。这是「一条发布分支 + tag 区分通道」的
|
||||
> 已知代价:beta 是为验证**打包链路**而补打的,不代表源码更旧。发布说明里已注明。
|
||||
|
||||
### 1.2.x 发布线 tag 历史
|
||||
|
||||
| tag | 提交 | 通道 | SDK | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `v1.2.0-beta.1` | `215804c` | beta | 不发 | 统一多模态向量空间 + 媒体升为图记忆一等节点 + 数据面全量迁到共享内存(RPC 协议 **2**,与 1.x 不兼容)。按 §七.2,beta 不伴随 SDK 发版 |
|
||||
| (正式 tag 待打) | — | — | — | 试运行 beta 无回退问题后打 `v1.2.0`,并同步 SDK 仓 `release/v1.2.x` + `v1.2.0` |
|
||||
|
||||
> 1.2.x 与存量插件**不兼容**:RPC 协议升到 2(fd3 布局改变),存量外部插件必须用
|
||||
> 新版 plugindev 重编为 `plugin.bin`——**不支持滚动升级**,内核与插件须同批重建、同批安装。
|
||||
> 按 §2.4,跳级直发正式版需在发布说明里列明「单点修复 / 反向验证 / 全类审计」三项;
|
||||
> 本次改动面大(统一多模态向量空间 + 协议 2 + 数据面全量迁移),不满足跳级条件。
|
||||
|
||||
---
|
||||
|
||||
## 四、现网部署与版本对应(运维纪律)
|
||||
@ -300,3 +352,15 @@ git branch -d release/v1.0.x # tag 已保存历史,
|
||||
两仓的 `main` 都遵守 §2.1:`meta.Version` 是**下一个未发布中版本**。
|
||||
所以在 1.1.x 线发布期间,两仓 main 上的值都是 `1.2.0`——它标记「main 正在积攒 1.2 的东西」,
|
||||
而不是「1.2.0 已经存在」。已发布的版本号一律看对应 `release/vX.Y.x` 分支与 tag。
|
||||
|
||||
**但两仓「同步推进」是有条件的**(这一点曾导致误判,现补写清楚):
|
||||
推进的前提是**该中版本已经正式发布过**。具体到当前:
|
||||
|
||||
- 核心:切出 `release/v1.2.x` 后,1.2.0 就归发布线所有,main 立即推进到 `1.3.0`;
|
||||
**即使 1.2.0 目前只有 beta tag**(beta 不上现网,但发布线已占住这个号)。
|
||||
- SDK:因为 §七.2 **beta 不发 SDK**,SDK 1.2.0 要等核心的**正式** tag 才定版、
|
||||
建 `release/v1.2.x`、打 `v1.2.0`(§七.3)。在那之前,SDK 的「下一个未发布中版本」
|
||||
仍然是 `1.2.0`,其 main 不得越过它。
|
||||
|
||||
→ 因此在这一阶段,**核心 main = `1.3.0` 而 SDK main = `1.2.0` 是正确的**,
|
||||
不是遗漏同步。(曾按本节的例子把 SDK main 也推到 1.3.0,等于宣称 SDK 1.2.0 已发布。)
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
> 状态:**完成 v3**(2026-09-06)——v2 的迁移已上生产(内核 v1.0.0);v3 记录 v1.1.1 的公开接口**扩展**。
|
||||
> 目的:钉死「暴露给外部插件的接口不变」这一约束的**合同面**——迁移前、迁移后外部插件看到/调用的 SDK 接口完全一致;
|
||||
> 所有改造落在**核心(homed 侧)+ 工具链(plugindev)**,外部插件业务代码零改动,只需用新 plugindev 重编。
|
||||
> 所有改造落在**核心(homed 侧)+ 工具链(hmapdev,当时名为 plugindev)**,外部插件业务代码零改动,只需用新工具链重编。
|
||||
>
|
||||
> **结果(已验证)**:`git diff third_party/homeagent-sdk/sdk/` 全程为空;17 个 `example/*/plugin.go` 逐字节未改
|
||||
> (`git status example/` 无输出);生产 17 插件全部经子进程通道运行。
|
||||
@ -11,7 +11,7 @@
|
||||
> 它要保的是「换运行模型不动业务代码」。迁移完成后,SDK 需要能随功能演进而扩展,
|
||||
> 否则多模态这类能力永远到不了插件手上。解除的边界见 §九:**只增不减,签名不改**。
|
||||
>
|
||||
> 维护规则:每次改动公开 SDK 接口面 `third_party/homeagent-sdk/sdk/` 或模板 `tools/plugindev/templates/` 后,
|
||||
> 维护规则:每次改动公开 SDK 接口面 `third_party/homeagent-sdk/sdk/` 或模板 `tools/hmapdev/templates/` 后,
|
||||
> 必须同步更新本矩阵。
|
||||
>
|
||||
> 权威编号:plan.md 第 11 节(11.1~11.9)。本文档只做接口面盘点,不做实现。
|
||||
@ -21,9 +21,9 @@
|
||||
## 一、迁移的形状(一句话)
|
||||
|
||||
```
|
||||
今天: 外部插件 = example/*/plugin.go(纯 Go) ──plugindev c-shared──> plugin.so
|
||||
今天: 外部插件 = example/*/plugin.go(纯 Go) ──hmapdev c-shared──> plugin.so
|
||||
homed ──dlopen──> plugin.so(C ABI bridge:51 个整数 method id)
|
||||
之后: 外部插件 = example/*/plugin.go(纯 Go,一行不改) ──plugindev go build──> plugin.bin
|
||||
之后: 外部插件 = example/*/plugin.go(纯 Go,一行不改) ──hmapdev go build──> plugin.bin
|
||||
homed ──spawn──> plugin.bin(stdio JSON-RPC + shm + eventfd)
|
||||
```
|
||||
|
||||
@ -33,8 +33,8 @@
|
||||
|---|---|---|
|
||||
| 公开 SDK `third_party/homeagent-sdk/sdk/*.go` | ❌ 纯 Go | **不动**(接口面 = 合同) |
|
||||
| 外部插件业务代码 `example/*/plugin.go` | ❌ 纯 Go(只 import 公开 SDK) | **不动**(只重编) |
|
||||
| bridge 模板 `tools/plugindev/templates.go` 的 `tmplLinuxBridge`/`tmplBridge` | ✅ cgo | **删除/替换**为 `tmplProcMain` |
|
||||
| `plugindev` 构建命令 | c-shared | 改普通 `go build` |
|
||||
| bridge 模板 `tools/hmapdev/templates.go` 的 `tmplLinuxBridge`/`tmplBridge` | ✅ cgo | **删除/替换**为 `tmplProcMain` |
|
||||
| `hmapdev` 构建命令 | c-shared | 改普通 `go build` |
|
||||
| homed `internal/plugin/cabi/`(1096 行) | cgo | 删(已归入 plan 迁移收尾 5.2) |
|
||||
| homed `internal/plugin/registry.go` 加载分派 | — | 改:按 `entry` 分派 `.so`/`.bin` |
|
||||
|
||||
@ -300,7 +300,7 @@ C 结构体不好传函数指针(那是运气,任何人给 dispatch 加个 c
|
||||
|
||||
## 七、接口冻结检查点(全部已通过)
|
||||
|
||||
1. ✅ **阶段 2(子进程通道原型)**:`plugindev` 重编 weather → `plugin.bin` → 端到端跑通。
|
||||
1. ✅ **阶段 2(子进程通道原型)**:`hmapdev` 重编 weather → `plugin.bin` → 端到端跑通。
|
||||
验收:weather 业务代码逐字节未改(`git status example/` 无输出)。
|
||||
2. ✅ **阶段 3(共享内存)**:子进程并发改写 StageContext 丢失率 = 0%
|
||||
(`TestPlugin_FiveProcessesConcurrentAppendNoLostUpdate` 与
|
||||
@ -349,7 +349,7 @@ tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
|
||||
|
||||
### 3. 生成模板必须同步接线,否则是**全体外部插件编译失败**
|
||||
|
||||
公开接口加方法时,`tools/plugindev/templates/proc_main.go.tmpl` 里的 `procIO` /
|
||||
公开接口加方法时,`tools/hmapdev/templates/proc_main.go.tmpl` 里的 `procIO` /
|
||||
`procDocMemory` 若不实现新方法,就不满足接口——**每个外部插件都编不过**,是硬失败
|
||||
不是软降级。v1.1.1 这一层是被 `go test` 抓出来的(`internal/plugin/proc` 的两个
|
||||
E2E 用例编译失败),不是靠人工检查发现的。
|
||||
@ -366,9 +366,48 @@ E2E 用例编译失败),不是靠人工检查发现的。
|
||||
|---|---|---|
|
||||
| 存量插件源码零改动 | `cd example/<n> && go vet ./...`(17 个) | ✅ 17/17 通过 |
|
||||
| 旧产物仍能建链 | 用 SDK 0.9.2 编的 `plugin.bin` 跑 `TestRealPlugin_*` | ✅ 4/4 通过(握手校验 `ProtocolVersion=1`,不是 SDK 版本) |
|
||||
| 模板已接线 | `cd tools/plugindev && go test ./...` | ✅ `TestProcTemplate_CoversAllCoreMethods` 含新 method |
|
||||
| 模板已接线 | `cd tools/hmapdev && go test ./...` | ✅ `TestProcTemplate_CoversAllCoreMethods` 含新 method |
|
||||
| 并发安全 | `go test ./sdk/ -race -count=5` | ✅ 零 DATA RACE(13 例压测) |
|
||||
|
||||
### v1.2.x 的接口扩展(2026-09-12)
|
||||
|
||||
1.2.0 把「记不记入记忆 / 要不要据此裁剪上下文」从**只有工具与通道能声明**,扩到**注入侧也能声明**:
|
||||
|
||||
| 新增 | 方向 | 说明 |
|
||||
|---|---|---|
|
||||
| `InjectOptions{NoMemory, ContextPolicy, CleanerName}` | 新增类型 | 单次注入的行为声明 |
|
||||
| `ContextPolicyNone` / `ContextPolicyPrune` + `ValidContextPolicy` | 新增常量/函数 | 取值只有 `""` / `none` / `prune`;`prune` 必须显式声明 |
|
||||
| 六个 `*Opts` 变体(Text / InterruptText / InputSync / InputMedia / InputMediaSync / InterruptMedia) | 插件调用、内核实现 | 旧的三参数方法保留为**零值糖**,与 `InjectOptions{}` 逐键等价 |
|
||||
| `ChannelDef.ContextPolicy` + `ChannelDef` 的 JSON tag | 结构体字段 | 通道也可声明裁剪;补 tag 是因为通道定义要跨进程传给内核,而 `Cleaner` 是函数必须忽略——无 tag 时新增字段会被**静默丢掉** |
|
||||
|
||||
签名层面零变更(六个方法全是新增),满足第 1、2 条。
|
||||
|
||||
**但「接口纯追加」不等于「无需重编」**:1.2.0 同时把插件运行协议升到 2
|
||||
(fd3 布局改变,不支持滚动升级),`ProtocolVersion` 不匹配会在握手时被明确拒绝
|
||||
并提示用配套 plugindev 重编。两件事必须分开说,否则会被误读成「既然纯追加就还能用旧产物」。
|
||||
|
||||
#### 这次扩展自己抓出来的两处漂移(都是本节第 3 条要防的那类)
|
||||
|
||||
1. **模板接线守卫红了**:`TestProcTemplate_CoversAllCoreMethods` 要求模板出现内核提供的
|
||||
每一个 method id,而注入标志位落地后模板不再发 `io.injectTextNoMem`(旧模板发它,
|
||||
现在走 `io.injectText` + `NoMemory` 标志位)。内核保留该 id 是**刻意的向后兼容面**
|
||||
(用那时模板编出的二进制仍在外面),不是漏接线——所以改的是判据:把它移入显式的
|
||||
`deprecated` 表,并加**反向保护**(条目一旦重新出现在模板里就报错,避免这张表
|
||||
退化成「永久豁免」的垃圾抽屉)。
|
||||
2. **mocksdk 缺一个方法**:拿公共 SDK `IOInjector` 的 14 个方法名与 mock 的方法集
|
||||
**机械求差**,差集恰好是旧的三参数 `InjectInputSync`——通道类插件(qq / a2a)完成
|
||||
「入站 → agent 处理 → 回复取回」闭环要调的那个。`git log -S` 证实它**从来就缺**,
|
||||
不是本次引入;补齐后差集为空。(上次漂的是 `Triple.Predicate` vs `Relation`,同一类问题。)
|
||||
|
||||
#### 验证(1.2.0,本机实测)
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
|---|---|---|
|
||||
| 存量插件源码零改动 | 逐个 `cd example/<n> && go vet ./...` | ✅ 17/17 通过(`luademo` 是 Lua、无 `go.mod`,跳过) |
|
||||
| 模板已接线 | `cd tools/plugindev && go test ./...` | ✅ 全绿(修复前为红;反向保护另用「把 id 塞回模板」验证过会报错) |
|
||||
| 并发安全 | `go test -race -count=5 ./sdk/` | ✅ ok |
|
||||
| mocksdk 未漂移 | 方法集求差(14 个方法) | ✅ 差集为空 |
|
||||
|
||||
### 为何媒体块走 JSON 而不是共享段二进制通道
|
||||
|
||||
`SetToolBlocks` 的原设计是「二进制落 arena,Slice 描述符回传」。实际落地时改走 JSON:
|
||||
@ -387,5 +426,5 @@ data URL 本身已是 base64 文本,包进二进制传输省不了空间,还
|
||||
- `internal/plugin/proc/protocol.go` — 合同面 B 的代码实现(`Method*` 常量,取代已删的 bridge 模板)
|
||||
- `internal/plugin/proc/shm.go` — 合同面 C 的代码实现(共享段布局与 18 字段枚举)
|
||||
- `internal/plugin/proc/capability.go` — 权限梯度(capability 组 + `withheldCapabilities`)
|
||||
- `third_party/homeagent-sdk/tools/plugindev/templates/` — 子进程运行时模板(三文件)
|
||||
- `third_party/homeagent-sdk/tools/hmapdev/templates/` — 子进程运行时模板(三文件)
|
||||
- `docs/zh/experiments/plugin-arch/` — 18 项可行性实验 + `19-migration-verify/` 迁移执行期工具
|
||||
@ -57,9 +57,13 @@ type Agent struct {
|
||||
// 由记忆系统本身决定。为 nil 时全部媒体接线静默跳过。
|
||||
mediaStore *media.Store
|
||||
|
||||
// 人格设定
|
||||
// 人格设定(内容来自启动时载入的人格文件/配置项)
|
||||
personality *agentPkg.Personality
|
||||
|
||||
// 人格落库面:首启门禁与 persona_set 工具使用(见 persona.go)。
|
||||
// 为 nil 时门禁与工具都静默关闭(例如单测里不接配置的场景)。
|
||||
personaStore PersonaStore
|
||||
|
||||
// 插件注册表(用于 plgreload)
|
||||
pluginReg *plugin.Registry
|
||||
pluginDir string
|
||||
@ -150,6 +154,12 @@ type Agent struct {
|
||||
// 也可以是外部 API 客户端;两者共享同一套 L0/L2/L3 向量缓存与检索基础设施。
|
||||
multimodalSpace vector.MultimodalEmbedder
|
||||
|
||||
// embeddingProvider 是配置里指定的统一向量空间 provider 名;
|
||||
// embeddingError 是打开/适配失败的原因(成功时为空)。
|
||||
// 二者只用于状态报告:区分「没配」「配了但打不开」「已启用」。
|
||||
embeddingProvider string
|
||||
embeddingError string
|
||||
|
||||
// fusionCfg 控制文本路与视觉路的跨模态融合权重,可按模型实测结果配置。
|
||||
fusionCfg CrossModalFusionConfig
|
||||
|
||||
@ -175,14 +185,19 @@ type AgentConfig struct {
|
||||
Indexer *memory.Indexer
|
||||
Tracker *tracker.Tracker
|
||||
|
||||
DocStore *document.Store
|
||||
Knowledge *knowledge.Store
|
||||
SocialStore *social.SocialStore
|
||||
TextMemory *text.Memory
|
||||
MediaStore *media.Store
|
||||
MultimodalSpace vector.MultimodalEmbedder
|
||||
DocStore *document.Store
|
||||
Knowledge *knowledge.Store
|
||||
SocialStore *social.SocialStore
|
||||
TextMemory *text.Memory
|
||||
MediaStore *media.Store
|
||||
MultimodalSpace vector.MultimodalEmbedder
|
||||
// EmbeddingProvider / EmbeddingError 是向量空间的配置身份与打开失败原因,
|
||||
// 供 healthcheck_kernel 状态报告区分「未配置 / 打开失败 / 已启用」。
|
||||
EmbeddingProvider string
|
||||
EmbeddingError string
|
||||
FusionCfg CrossModalFusionConfig // 跨模态融合权重;零值用默认
|
||||
Personality *agentPkg.Personality
|
||||
PersonaStore PersonaStore // 人格设定的读写面(首启门禁 + persona_set 工具)
|
||||
PluginReg *plugin.Registry
|
||||
PluginDir string
|
||||
DistillInterval time.Duration
|
||||
@ -251,45 +266,48 @@ func New(cfg AgentConfig) *Agent {
|
||||
}
|
||||
|
||||
return &Agent{
|
||||
id: cfg.ID,
|
||||
startTime: time.Now(),
|
||||
provider: cfg.Provider,
|
||||
providerManager: cfg.ProviderManager,
|
||||
io: cfg.IO,
|
||||
memory: cfg.Memory,
|
||||
indexer: cfg.Indexer,
|
||||
tracker: cfg.Tracker,
|
||||
context: rc,
|
||||
systemPrompt: cfg.SystemPrompt,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
docStore: cfg.DocStore,
|
||||
knowledge: cfg.Knowledge,
|
||||
social: cfg.SocialStore,
|
||||
textMem: cfg.TextMemory,
|
||||
mediaStore: cfg.MediaStore,
|
||||
personality: cfg.Personality,
|
||||
pluginReg: cfg.PluginReg,
|
||||
pluginDir: cfg.PluginDir,
|
||||
distillInterval: cfg.DistillInterval,
|
||||
archiveInterval: cfg.ArchiveInterval,
|
||||
reviewInterval: cfg.ReviewInterval,
|
||||
mergeInterval: cfg.MergeInterval,
|
||||
maxContextSize: cfg.MaxContextSize,
|
||||
stageHost: cfg.StageHost,
|
||||
skillIndex: cfg.SkillIndexProvider,
|
||||
eventBus: cfg.EventBus,
|
||||
selfInputCh: make(chan selfInputMsg, 64),
|
||||
childTasks: make(map[string]*childTaskState),
|
||||
interceptCh: make(chan *agentIO.InputEvent, 64),
|
||||
pluginHealth: newPluginHealthTracker(),
|
||||
thinkingEnabled: cfg.ThinkingEnabled,
|
||||
inputCfg: cfg.InputProcessing,
|
||||
embedder: embedder,
|
||||
multimodalSpace: cfg.MultimodalSpace,
|
||||
fusionCfg: cfg.FusionCfg,
|
||||
noMergeMarkers: make(map[string]int),
|
||||
lastInput: make(map[string]time.Time),
|
||||
id: cfg.ID,
|
||||
startTime: time.Now(),
|
||||
provider: cfg.Provider,
|
||||
providerManager: cfg.ProviderManager,
|
||||
io: cfg.IO,
|
||||
memory: cfg.Memory,
|
||||
indexer: cfg.Indexer,
|
||||
tracker: cfg.Tracker,
|
||||
context: rc,
|
||||
systemPrompt: cfg.SystemPrompt,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
docStore: cfg.DocStore,
|
||||
knowledge: cfg.Knowledge,
|
||||
social: cfg.SocialStore,
|
||||
textMem: cfg.TextMemory,
|
||||
mediaStore: cfg.MediaStore,
|
||||
personality: cfg.Personality,
|
||||
personaStore: cfg.PersonaStore,
|
||||
pluginReg: cfg.PluginReg,
|
||||
pluginDir: cfg.PluginDir,
|
||||
distillInterval: cfg.DistillInterval,
|
||||
archiveInterval: cfg.ArchiveInterval,
|
||||
reviewInterval: cfg.ReviewInterval,
|
||||
mergeInterval: cfg.MergeInterval,
|
||||
maxContextSize: cfg.MaxContextSize,
|
||||
stageHost: cfg.StageHost,
|
||||
skillIndex: cfg.SkillIndexProvider,
|
||||
eventBus: cfg.EventBus,
|
||||
selfInputCh: make(chan selfInputMsg, 64),
|
||||
childTasks: make(map[string]*childTaskState),
|
||||
interceptCh: make(chan *agentIO.InputEvent, 64),
|
||||
pluginHealth: newPluginHealthTracker(),
|
||||
thinkingEnabled: cfg.ThinkingEnabled,
|
||||
inputCfg: cfg.InputProcessing,
|
||||
embedder: embedder,
|
||||
multimodalSpace: cfg.MultimodalSpace,
|
||||
embeddingProvider: cfg.EmbeddingProvider,
|
||||
embeddingError: cfg.EmbeddingError,
|
||||
fusionCfg: cfg.FusionCfg,
|
||||
noMergeMarkers: make(map[string]int),
|
||||
lastInput: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
54
internal/agent/core/persona.go
Normal file
54
internal/agent/core/persona.go
Normal file
@ -0,0 +1,54 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
)
|
||||
|
||||
// PersonaStore 是人格设定的读写面。
|
||||
//
|
||||
// 首启门禁(buildSystemPrompt)与 persona_set 工具都通过它工作,实现在 cmd/homed:
|
||||
// 读写配置项 core.agent.personal_prompt 与一次性标记 core.internal.persona_initialized
|
||||
// (落库逻辑与 WebUI 向导共用 internal/config 的实现)。
|
||||
//
|
||||
// 为什么放在内核而不是某个通道插件:人格是**任何通道都要问一次**的事。
|
||||
// 系统提示词每轮重建,门禁放在这里,WebUI / QQ / CLI / ACP / 邮件等全部通道自动覆盖。
|
||||
type PersonaStore interface {
|
||||
// PersonaInitialized 报告人格是否已确认(向导或工具已问过)。
|
||||
PersonaInitialized() bool
|
||||
// SetPersona 落库人格并打一次性标记,返回是否需要重启才生效。
|
||||
SetPersona(mode, content string) (restartRequired bool, err error)
|
||||
}
|
||||
|
||||
// executePersonaTool 落地首启人格设定。
|
||||
//
|
||||
// 成功即打一次性标记 → 之后 buildSystemPrompt 不再要求模型询问人格。
|
||||
// custom 模式返回「需重启生效」:人格在 homed 启动时载入。
|
||||
func (a *Agent) executePersonaTool(tc agentAPI.ToolCall) string {
|
||||
if a.personaStore == nil {
|
||||
return "人格设定不可用:内核未接入配置"
|
||||
}
|
||||
mode, _ := tc.Arguments["mode"].(string)
|
||||
content, _ := tc.Arguments["content"].(string)
|
||||
mode = strings.TrimSpace(mode)
|
||||
restart, err := a.personaStore.SetPersona(mode, content)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("人格设定失败:%v", err)
|
||||
}
|
||||
switch mode {
|
||||
case "custom":
|
||||
msg := "已保存自定义人格"
|
||||
if restart {
|
||||
msg += ";**重启 homed 后生效**(人格在启动时载入)"
|
||||
}
|
||||
return msg
|
||||
case "default":
|
||||
return "已确认使用默认人格"
|
||||
case "later":
|
||||
return "已记为「以后再说」,继续使用默认人格"
|
||||
default:
|
||||
return "已保存人格设定"
|
||||
}
|
||||
}
|
||||
112
internal/agent/core/persona_test.go
Normal file
112
internal/agent/core/persona_test.go
Normal file
@ -0,0 +1,112 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// newTestAgent 造一个最小可用的 Agent(buildToolDefs 要求 io 非 nil)。
|
||||
func newTestAgent(st PersonaStore) *Agent {
|
||||
return &Agent{io: agentIO.NewIOManager(), personaStore: st}
|
||||
}
|
||||
|
||||
// fakePersonaStore 记录调用并可控地报告「是否已确认」。
|
||||
type fakePersonaStore struct {
|
||||
initialized bool
|
||||
mode string
|
||||
content string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakePersonaStore) PersonaInitialized() bool { return f.initialized }
|
||||
|
||||
func (f *fakePersonaStore) SetPersona(mode, content string) (bool, error) {
|
||||
f.calls++
|
||||
f.mode, f.content = mode, content
|
||||
f.initialized = true
|
||||
return mode == "custom", nil
|
||||
}
|
||||
|
||||
// 首启门禁:人格未确认时,**任何通道**的系统提示词都必须带上「去问用户」的指令;
|
||||
// 确认后必须消失(否则会每轮反复追问)。
|
||||
func TestPersonaOnboardingGateInSystemPrompt(t *testing.T) {
|
||||
st := &fakePersonaStore{}
|
||||
a := newTestAgent(st)
|
||||
|
||||
p := a.buildSystemPrompt("", "你好")
|
||||
if !strings.Contains(p, "首启人格设定") || !strings.Contains(p, "persona_set") {
|
||||
t.Fatalf("未确认人格时提示词应要求模型询问并调用 persona_set,实际缺少该段")
|
||||
}
|
||||
|
||||
// 模型落地后(标记置位)不再出现
|
||||
if out := a.executePersonaTool(agentAPI.ToolCall{Name: "persona_set",
|
||||
Arguments: map[string]interface{}{"mode": "default"}}); !strings.Contains(out, "默认人格") {
|
||||
t.Fatalf("persona_set(default) 回执不对: %s", out)
|
||||
}
|
||||
if !st.initialized {
|
||||
t.Fatal("落库后应置位标记")
|
||||
}
|
||||
if p2 := a.buildSystemPrompt("", "你好"); strings.Contains(p2, "首启人格设定") {
|
||||
t.Fatal("人格已确认后不应再要求询问")
|
||||
}
|
||||
|
||||
// 未接入配置(personaStore 为 nil)时,门禁与工具都必须静默关闭
|
||||
b := newTestAgent(nil)
|
||||
if pb := b.buildSystemPrompt("", "你好"); strings.Contains(pb, "首启人格设定") {
|
||||
t.Fatal("未接入配置时不应出现首启门禁")
|
||||
}
|
||||
if out := b.executePersonaTool(agentAPI.ToolCall{Name: "persona_set"}); !strings.Contains(out, "不可用") {
|
||||
t.Fatalf("未接入配置时工具应回明确错误,实际: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// persona_set 的三选一语义与回执。
|
||||
func TestPersonaSetToolModes(t *testing.T) {
|
||||
cases := []struct {
|
||||
mode, content, want string
|
||||
}{
|
||||
{"custom", "你是测试人格", "重启"},
|
||||
{"default", "", "默认人格"},
|
||||
{"later", "", "以后再说"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
st := &fakePersonaStore{}
|
||||
a := newTestAgent(st)
|
||||
out := a.executePersonaTool(agentAPI.ToolCall{Name: "persona_set",
|
||||
Arguments: map[string]interface{}{"mode": c.mode, "content": c.content}})
|
||||
if !strings.Contains(out, c.want) {
|
||||
t.Errorf("mode=%s 回执应含 %q,实际: %s", c.mode, c.want, out)
|
||||
}
|
||||
if st.calls != 1 || st.mode != c.mode || st.content != c.content {
|
||||
t.Errorf("mode=%s 落库参数不对: calls=%d mode=%s content=%q", c.mode, st.calls, st.mode, st.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 工具 schema 必须在 catalog 里出现(模型才可能调用)。
|
||||
func TestPersonaSetToolDefPresent(t *testing.T) {
|
||||
a := newTestAgent(&fakePersonaStore{})
|
||||
found := false
|
||||
for _, td := range a.buildToolDefs() {
|
||||
if m, ok := td.(map[string]interface{}); ok {
|
||||
if fn, ok := m["function"].(map[string]interface{}); ok && fn["name"] == "persona_set" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("buildToolDefs 未包含 persona_set")
|
||||
}
|
||||
// 未接入配置时不应暴露该工具
|
||||
b := newTestAgent(nil)
|
||||
for _, td := range b.buildToolDefs() {
|
||||
if m, ok := td.(map[string]interface{}); ok {
|
||||
if fn, ok := m["function"].(map[string]interface{}); ok && fn["name"] == "persona_set" {
|
||||
t.Fatal("未接入配置时不应暴露 persona_set")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -73,6 +73,8 @@ func collectKernelStatus(
|
||||
BuildTime: meta.BuildTime,
|
||||
SDKCompatible: meta.SDKCompatibleVersion,
|
||||
KernelName: meta.KernelName,
|
||||
// AGPL-3.0 §13:状态页向网络使用者展示取得源码的入口。
|
||||
SourceURL: meta.SourceURL,
|
||||
},
|
||||
Runtime: RuntimeStatus{
|
||||
Goroutines: runtime.NumGoroutine(),
|
||||
@ -196,6 +198,14 @@ func (a *Agent) GetKernelStatus() *KernelStatus {
|
||||
trk = a.tracker
|
||||
}
|
||||
|
||||
// 注意:knowledge 在 collectKernelStatus 里是**接口**参数,
|
||||
// 而 (*knowledge.Store)(nil) 塞进接口后 `ks != nil` 仍为真 → 调 List() 直接 panic。
|
||||
// 所以这里必须先判具体指针再进行接口赋值(healthcheck_kernel 会走到这条路径)。
|
||||
var knowledgeLister interface{ List() []string }
|
||||
if a.knowledge != nil {
|
||||
knowledgeLister = a.knowledge
|
||||
}
|
||||
|
||||
ks := collectKernelStatus(
|
||||
a.startTime,
|
||||
string(a.id),
|
||||
@ -205,15 +215,43 @@ func (a *Agent) GetKernelStatus() *KernelStatus {
|
||||
a.io,
|
||||
a.pluginReg,
|
||||
a.memory,
|
||||
a.knowledge,
|
||||
knowledgeLister,
|
||||
a.docStore,
|
||||
textMem,
|
||||
socialStore,
|
||||
trk,
|
||||
)
|
||||
ks.ONNX = a.onnxStatus()
|
||||
|
||||
return ks
|
||||
}
|
||||
|
||||
// onnxStatus 汇总统一多模态向量空间(ONNX 模型)的启用状态。
|
||||
//
|
||||
// 判据是 Loaded()(provider 真正打开且元数据合法),**不是**「配置里写了 provider」——
|
||||
// 后者在模型缺失 / 运行时缺失时也为真,拿它当判据就是假绿。
|
||||
func (a *Agent) onnxStatus() sdk.ONNXStatus {
|
||||
st := sdk.ONNXStatus{Provider: a.embeddingProvider}
|
||||
if a.multimodalSpace != nil && a.multimodalSpace.Loaded() {
|
||||
st.Enabled = true
|
||||
st.Dim = a.multimodalSpace.Dim()
|
||||
st.Fingerprint = a.multimodalSpace.Fingerprint()
|
||||
// 模态是可选能力:只有底层 provider 报出来时才带出。
|
||||
if mr, ok := a.multimodalSpace.(interface{ Modalities() []string }); ok {
|
||||
st.Modalities = mr.Modalities()
|
||||
}
|
||||
return st
|
||||
}
|
||||
switch {
|
||||
case a.embeddingError != "":
|
||||
st.Reason = "打开失败: " + a.embeddingError
|
||||
case a.embeddingProvider == "":
|
||||
st.Reason = "未配置统一向量空间 provider(走词嵌入/TF-IDF 回退路径)"
|
||||
default:
|
||||
st.Reason = "provider 未加载"
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
var _ StatusProvider = (*Agent)(nil)
|
||||
var _ sdk.StatusAPI = (*Agent)(nil)
|
||||
|
||||
103
internal/agent/core/status_onnx_test.go
Normal file
103
internal/agent/core/status_onnx_test.go
Normal file
@ -0,0 +1,103 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
)
|
||||
|
||||
// statusSpace 是带模态元数据的假统一空间(ProviderAdapter 的 Modalities() 形状)。
|
||||
type statusSpace struct{ fakeSpace }
|
||||
|
||||
func (statusSpace) Modalities() []string { return []string{"text", "image"} }
|
||||
|
||||
// statusSpaceUnloaded 模拟「provider 建了但没加载成功」。
|
||||
type statusSpaceUnloaded struct{ fakeSpace }
|
||||
|
||||
func (statusSpaceUnloaded) Loaded() bool { return false }
|
||||
|
||||
// healthcheck_kernel 必须能回答两件事:内核版本号、以及**是否真的启用了 ONNX 模型**。
|
||||
//
|
||||
// 判据要点:只有 provider 真正 Loaded() 才算启用 ——「配置里写了 provider」不算,
|
||||
// 否则模型缺失/运行时缺失时会报成已启用(假绿)。
|
||||
func TestKernelStatusReportsVersionAndONNX(t *testing.T) {
|
||||
t.Run("已启用:带出 provider/维度/指纹/模态", func(t *testing.T) {
|
||||
a := &Agent{
|
||||
io: agentIO.NewIOManager(),
|
||||
multimodalSpace: statusSpace{},
|
||||
embeddingProvider: "chineseclip",
|
||||
}
|
||||
st := a.GetKernelStatus()
|
||||
if !st.ONNX.Enabled {
|
||||
t.Fatal("Loaded() 为真时 onnx.enabled 必须为真")
|
||||
}
|
||||
if st.ONNX.Provider != "chineseclip" || st.ONNX.Dim != 2 || st.ONNX.Fingerprint != "fake-space" {
|
||||
t.Fatalf("onnx 身份字段不对: %+v", st.ONNX)
|
||||
}
|
||||
if len(st.ONNX.Modalities) != 2 || st.ONNX.Reason != "" {
|
||||
t.Fatalf("模态/原因不对: %+v", st.ONNX)
|
||||
}
|
||||
// 内核版本号必须随状态一起报:人格卡要求「版本以运行时快照为准」靠的就是这一项
|
||||
if st.Build.Version == "" || st.Build.KernelName == "" {
|
||||
t.Fatalf("build 段缺少版本/内核名: %+v", st.Build)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("打开失败:enabled=false 且给出具体原因", func(t *testing.T) {
|
||||
a := &Agent{
|
||||
io: agentIO.NewIOManager(),
|
||||
embeddingProvider: "chineseclip",
|
||||
embeddingError: `embedding: open provider "chineseclip": model dir missing`,
|
||||
}
|
||||
st := a.GetKernelStatus()
|
||||
if st.ONNX.Enabled {
|
||||
t.Fatal("打开失败时不能报 enabled")
|
||||
}
|
||||
if st.ONNX.Provider != "chineseclip" {
|
||||
t.Fatalf("未启用时仍应带出配置的 provider: %+v", st.ONNX)
|
||||
}
|
||||
if !strings.Contains(st.ONNX.Reason, "model dir missing") {
|
||||
t.Fatalf("原因应包含具体错误: %q", st.ONNX.Reason)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("未配置:说明会走回退路径", func(t *testing.T) {
|
||||
a := &Agent{io: agentIO.NewIOManager()}
|
||||
st := a.GetKernelStatus()
|
||||
if st.ONNX.Enabled || st.ONNX.Reason == "" {
|
||||
t.Fatalf("未配置时应 enabled=false 且有原因: %+v", st.ONNX)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("provider 存在但未加载", func(t *testing.T) {
|
||||
a := &Agent{
|
||||
io: agentIO.NewIOManager(),
|
||||
multimodalSpace: statusSpaceUnloaded{},
|
||||
embeddingProvider: "qwen3vl",
|
||||
}
|
||||
st := a.GetKernelStatus()
|
||||
if st.ONNX.Enabled {
|
||||
t.Fatal("Loaded() 为假时不能报 enabled")
|
||||
}
|
||||
if st.ONNX.Reason == "" {
|
||||
t.Fatalf("应给出未加载的原因: %+v", st.ONNX)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// collectKernelStatus 的 knowledge 参数是**接口**类型,而 (*knowledge.Store)(nil)
|
||||
// 塞进接口后 `ks != nil` 仍为真 → 调 List() 直接 panic。
|
||||
// 这条测试钉住这个成因:一旦不再 panic,说明参数形状变了,
|
||||
// GetKernelStatus 里的 typed-nil 守卫就该同步删掉(否则它变成无意义代码)。
|
||||
func TestCollectKernelStatusTypedNilKnowledgePanics(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Fatal("typed-nil 交给接口参数却未 panic:成因已变,请更新守卫与本测试")
|
||||
}
|
||||
}()
|
||||
var nilStore *knowledge.Store
|
||||
_ = collectKernelStatus(time.Now(), "a", "", 0, nil, nil, nil, nil, nilStore, nil, nil, nil, nil)
|
||||
}
|
||||
@ -45,6 +45,8 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall) (ret string) {
|
||||
|
||||
func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall) string {
|
||||
switch {
|
||||
case tc.Name == "persona_set":
|
||||
return a.executePersonaTool(tc)
|
||||
case strings.HasPrefix(tc.Name, "memory_"):
|
||||
return a.executeMemoryTool(tc)
|
||||
case strings.HasPrefix(tc.Name, "social_"):
|
||||
|
||||
@ -89,6 +89,16 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 首启人格门禁(跨通道唯一闸口):人格未确认时,要求模型主动询问用户。
|
||||
// 系统提示词每轮重建,因此 WebUI / QQ / CLI / ACP / 邮件等所有通道都会带上它;
|
||||
// 模型调用 persona_set(或用户在 WebUI 向导里选)落地后,标记置位,本段消失。
|
||||
if a.personaStore != nil && !a.personaStore.PersonaInitialized() {
|
||||
prompt += "\n\n【首启人格设定】你的**人格设定尚未确认**。请在本轮回复里先问用户一句:" +
|
||||
"要用默认人格,还是自定义一个?拿到明确答复后**必须调用 persona_set 工具**落库:" +
|
||||
"用户选默认 → mode=default;自定义 → mode=custom 且把内容写进 content;" +
|
||||
"用户说以后再说 → mode=later。用户答复前不要假设已设置,也不要反复追问同一件事。"
|
||||
}
|
||||
|
||||
prompt += a.buildToolCatalog()
|
||||
|
||||
return prompt
|
||||
@ -197,6 +207,24 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
if a.personaStore != nil {
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "persona_set",
|
||||
"description": "【首启人格】落地用户的人格选择并记录「已经问过」。仅在用户明确答复后调用:默认用 mode=default;自定义用 mode=custom 并把人格内容放进 content;用户说以后再说用 mode=later。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"mode": map[string]interface{}{"type": "string", "description": "default | custom | later"},
|
||||
"content": map[string]interface{}{"type": "string", "description": "自定义人格内容(mode=custom 时必填)"},
|
||||
},
|
||||
"required": []string{"mode"},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if a.memory != nil {
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
|
||||
@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Personality struct {
|
||||
@ -39,3 +41,25 @@ func (p *Personality) InjectPrompt() string {
|
||||
}
|
||||
return fmt.Sprintf("【人格设定】\n%s\n", p.Content)
|
||||
}
|
||||
|
||||
// 会随时间腐坏的人格内容特征。现场:人格卡写死 v0.9.0 与早已删除的 C ABI v2,
|
||||
// 实例被问版本时自述错误(v1.2.0 压测发现)。
|
||||
var (
|
||||
personaVersionRe = regexp.MustCompile(`\bv?\d+\.\d+\.\d+\b`)
|
||||
personaStalePhrases = []string{"C ABI v2", "plugin.so", "c-shared", "描述式索引", "引用计数式"}
|
||||
)
|
||||
|
||||
// PersonaStaleHints 返回人格文本里会腐坏的内容(空 = 干净)。
|
||||
// 供启动时告警:引导改用配置项 core.agent.personal_prompt(默认模板不含这些)。
|
||||
func PersonaStaleHints(content string) []string {
|
||||
var out []string
|
||||
if m := personaVersionRe.FindAllString(content, -1); len(m) > 0 {
|
||||
out = append(out, fmt.Sprintf("版本号字面量 %v(版本应来自运行时快照)", m))
|
||||
}
|
||||
for _, p := range personaStalePhrases {
|
||||
if strings.Contains(content, p) {
|
||||
out = append(out, "可能已过期的说法: "+p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
62
internal/agent/personal_test.go
Normal file
62
internal/agent/personal_test.go
Normal file
@ -0,0 +1,62 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// PersonaStaleHints 必须能认出会腐坏的人格内容——版本号字面量与已删除机制。
|
||||
func TestPersonaStaleHints(t *testing.T) {
|
||||
// 现场真实文本(线上人格卡的原文)
|
||||
stale := "你是 HomeAgent(内核代号 HΔ-Kernel,当前版本 v0.9.0)。\n当前运行的二进制是 v0.9.0(C ABI v2,构建于 2026-08-15)。"
|
||||
hints := PersonaStaleHints(stale)
|
||||
if len(hints) == 0 {
|
||||
t.Fatal("未识别出写死版本号与 C ABI 的人格文本")
|
||||
}
|
||||
joined := strings.Join(hints, " | ")
|
||||
if !strings.Contains(joined, "版本号字面量") {
|
||||
t.Fatalf("应报出版本号字面量,实际: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "C ABI v2") {
|
||||
t.Fatalf("应报出已删除机制的残留说法,实际: %s", joined)
|
||||
}
|
||||
|
||||
// 干净文本(配置项默认模板)不应误报
|
||||
if h := PersonaStaleHints(DefaultPersonaProbeClean()); len(h) != 0 {
|
||||
t.Fatalf("干净人格被误报: %v", h)
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultPersonaProbeClean 由 config 包的默认模板等价物构成——
|
||||
// 这里不复用 config 包以避免 import cycle,只断言「不含版本号与旧机制」的文本不被误报。
|
||||
func DefaultPersonaProbeClean() string {
|
||||
return "你是 HomeAgent(内核代号 HΔ-Kernel)。外部插件是独立子进程,经 stdio JSON-RPC 通信;" +
|
||||
"被问到版本时以运行时快照为准。"
|
||||
}
|
||||
|
||||
func TestLoadAndSavePersonality(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "personal", "personal.md")
|
||||
|
||||
// 文件不存在时返回空(不报错、不创建)
|
||||
p, err := LoadPersonality(path)
|
||||
if err != nil || p == nil || p.Content != "" {
|
||||
t.Fatalf("缺文件时应返回空人格,实际 %+v err=%v", p, err)
|
||||
}
|
||||
|
||||
if err := SavePersonality(path, "人格内容"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("SavePersonality 未落盘: %v", err)
|
||||
}
|
||||
p, err = LoadPersonality(path)
|
||||
if err != nil || p.Content != "人格内容" {
|
||||
t.Fatalf("读回失败: %+v err=%v", p, err)
|
||||
}
|
||||
if got := p.InjectPrompt(); !strings.Contains(got, "【人格设定】") || !strings.Contains(got, "人格内容") {
|
||||
t.Fatalf("InjectPrompt 形状不对: %q", got)
|
||||
}
|
||||
}
|
||||
139
internal/config/persona.go
Normal file
139
internal/config/persona.go
Normal file
@ -0,0 +1,139 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 人格设定的两个键:内容与「首启向导/工具已经问过」的一次性标记。
|
||||
//
|
||||
// 为什么需要标记:人格曾经只有 <dataDir>/personal/personal.md 一个来源且无人维护,
|
||||
// 里面写死的旧版本号反过来让实例自述旧版本(v1.2.0 压测发现)。
|
||||
// 现在人格是配置项(默认模板不含任何版本号),任何通道的第一次交互问一次,之后不再打扰。
|
||||
const (
|
||||
// PersonaPromptKey 是人格设定内容(【人格设定】块的正文)。
|
||||
PersonaPromptKey = "core.agent.personal_prompt"
|
||||
// PersonaInitMarkerKey 是「已经问过/已确认」的一次性标记。
|
||||
PersonaInitMarkerKey = "core.internal.persona_initialized"
|
||||
)
|
||||
|
||||
// 三种落库方式,WebUI 首启向导与内核 persona_set 工具共用。
|
||||
const (
|
||||
PersonaModeDefault = "default" // 使用内置默认模板
|
||||
PersonaModeCustom = "custom" // 使用调用方提供的内容
|
||||
PersonaModeLater = "later" // 保留当前(默认)人格,只打标记不再问
|
||||
)
|
||||
|
||||
// PersonaKV 是人格落库所需的最小读写面:GetCore/SetCore 的签名与
|
||||
// 公开 SDK 的 SettingsAPI 一致,因此插件侧(WebUI)可直接传入;
|
||||
// 内核直连配置注册表时用 registryKV 适配(见文件末)。
|
||||
type PersonaKV interface {
|
||||
GetCore(key string) (interface{}, error)
|
||||
SetCore(key string, value interface{}) error
|
||||
}
|
||||
|
||||
// PersonaInitializedKV 报告人格是否已确认(向导或工具已问过)。
|
||||
func PersonaInitializedKV(kv PersonaKV) bool {
|
||||
if kv == nil {
|
||||
return false
|
||||
}
|
||||
v, err := kv.GetCore(PersonaInitMarkerKey)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
s, _ := v.(string)
|
||||
return strings.TrimSpace(s) != ""
|
||||
}
|
||||
|
||||
// CurrentPersonaKV 读当前人格内容;未设置时回落到内置默认模板。
|
||||
func CurrentPersonaKV(kv PersonaKV) string {
|
||||
if kv == nil {
|
||||
return DefaultPersonaPrompt
|
||||
}
|
||||
if v, err := kv.GetCore(PersonaPromptKey); err == nil {
|
||||
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return DefaultPersonaPrompt
|
||||
}
|
||||
|
||||
// SetPersonaKV 落库人格并打一次性标记,返回 restartRequired。
|
||||
//
|
||||
// 生效时机:人格在 homed 启动时载入(拼成【人格设定】块进系统提示词),
|
||||
// 所以**自定义内容需重启**;default 与 later 都不改变当前已生效的人格,无需重启。
|
||||
//
|
||||
// 非法输入一律在打标记之前拒绝——否则向导会被跳过,用户再也没机会设。
|
||||
func SetPersonaKV(kv PersonaKV, mode, content string) (restartRequired bool, err error) {
|
||||
if kv == nil {
|
||||
return false, fmt.Errorf("persona: settings unavailable")
|
||||
}
|
||||
switch mode {
|
||||
case PersonaModeDefault:
|
||||
if err := kv.SetCore(PersonaPromptKey, DefaultPersonaPrompt); err != nil {
|
||||
return false, err
|
||||
}
|
||||
case PersonaModeCustom:
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return false, fmt.Errorf("persona: content required for custom mode")
|
||||
}
|
||||
if err := kv.SetCore(PersonaPromptKey, content); err != nil {
|
||||
return false, err
|
||||
}
|
||||
restartRequired = true
|
||||
case PersonaModeLater:
|
||||
// 保持当前人格(通常是默认模板),只打标记
|
||||
default:
|
||||
return false, fmt.Errorf("persona: unknown mode %q (want default|custom|later)", mode)
|
||||
}
|
||||
if err := kv.SetCore(PersonaInitMarkerKey, "1"); err != nil {
|
||||
return restartRequired, err
|
||||
}
|
||||
return restartRequired, nil
|
||||
}
|
||||
|
||||
// RegistryPersonaStore 把配置注册表暴露成内核的 core.PersonaStore 接口
|
||||
// (结构类型:方法集匹配即可,无需 import internal/agent/core)。
|
||||
type RegistryPersonaStore struct{ Reg *ConfigRegistry }
|
||||
|
||||
func (p RegistryPersonaStore) PersonaInitialized() bool { return PersonaInitialized(p.Reg) }
|
||||
|
||||
func (p RegistryPersonaStore) SetPersona(mode, content string) (bool, error) {
|
||||
return SetPersona(p.Reg, mode, content)
|
||||
}
|
||||
|
||||
// registryKV 把内核直连的配置注册表适配成 PersonaKV。
|
||||
type registryKV struct{ reg *ConfigRegistry }
|
||||
|
||||
func (r registryKV) GetCore(key string) (interface{}, error) {
|
||||
v, err := r.reg.Get(key)
|
||||
if err != nil || v == nil {
|
||||
// 未设置的键对 PersonaKV 语义等同「没有」,不当作错误
|
||||
if s := r.reg.GetString(key, ""); s != "" {
|
||||
return s, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (r registryKV) SetCore(key string, value interface{}) error {
|
||||
s, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("persona: value must be a string")
|
||||
}
|
||||
return r.reg.Set(key, s)
|
||||
}
|
||||
|
||||
// PersonaInitialized 报告人格是否已确认(内核直连注册表)。
|
||||
func PersonaInitialized(reg *ConfigRegistry) bool {
|
||||
return reg != nil && PersonaInitializedKV(registryKV{reg})
|
||||
}
|
||||
|
||||
// SetPersona 落库人格并打一次性标记(内核直连注册表)。
|
||||
func SetPersona(reg *ConfigRegistry, mode, content string) (bool, error) {
|
||||
if reg == nil {
|
||||
return false, fmt.Errorf("persona: config registry unavailable")
|
||||
}
|
||||
return SetPersonaKV(registryKV{reg}, mode, content)
|
||||
}
|
||||
99
internal/config/persona_kv_test.go
Normal file
99
internal/config/persona_kv_test.go
Normal file
@ -0,0 +1,99 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeKV 是 PersonaKV 的最小实现(模拟插件侧 SettingsAPI)。
|
||||
type fakeKV struct{ m map[string]string }
|
||||
|
||||
func (f *fakeKV) GetCore(k string) (interface{}, error) { return f.m[k], nil }
|
||||
func (f *fakeKV) SetCore(k string, v interface{}) error {
|
||||
f.m[k] = v.(string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 三选一语义 + 「只问一次」标记:这是首启向导与 persona_set 工具共用的同一份实现。
|
||||
func TestSetPersonaKV(t *testing.T) {
|
||||
t.Run("custom 写入内容并要求重启", func(t *testing.T) {
|
||||
kv := &fakeKV{m: map[string]string{}}
|
||||
restart, err := SetPersonaKV(kv, PersonaModeCustom, "你是测试人格")
|
||||
if err != nil || !restart {
|
||||
t.Fatalf("custom 应成功且需重启: restart=%v err=%v", restart, err)
|
||||
}
|
||||
if kv.m[PersonaPromptKey] != "你是测试人格" || kv.m[PersonaInitMarkerKey] != "1" {
|
||||
t.Fatalf("落库不对: %+v", kv.m)
|
||||
}
|
||||
if !PersonaInitializedKV(kv) {
|
||||
t.Fatal("打过标记应报告已确认")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default 写默认模板且无需重启", func(t *testing.T) {
|
||||
kv := &fakeKV{m: map[string]string{}}
|
||||
restart, err := SetPersonaKV(kv, PersonaModeDefault, "")
|
||||
if err != nil || restart {
|
||||
t.Fatalf("default 不应需重启: restart=%v err=%v", restart, err)
|
||||
}
|
||||
if kv.m[PersonaPromptKey] != DefaultPersonaPrompt {
|
||||
t.Fatal("default 应写入内置默认模板")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("later 保持人格并打标记", func(t *testing.T) {
|
||||
kv := &fakeKV{m: map[string]string{PersonaPromptKey: "原有人格"}}
|
||||
if _, err := SetPersonaKV(kv, PersonaModeLater, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if kv.m[PersonaPromptKey] != "原有人格" {
|
||||
t.Fatal("later 不应改动人格")
|
||||
}
|
||||
if kv.m[PersonaInitMarkerKey] != "1" {
|
||||
t.Fatal("later 也必须打标记(否则每次启动都问)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("非法输入必须拒绝且不打标记", func(t *testing.T) {
|
||||
for _, c := range []struct{ mode, content string }{
|
||||
{PersonaModeCustom, " "}, // 空内容
|
||||
{"nope", ""}, // 未知 mode
|
||||
} {
|
||||
kv := &fakeKV{m: map[string]string{}}
|
||||
if _, err := SetPersonaKV(kv, c.mode, c.content); err == nil {
|
||||
t.Fatalf("mode=%q content=%q 应报错", c.mode, c.content)
|
||||
}
|
||||
if kv.m[PersonaInitMarkerKey] == "1" {
|
||||
t.Fatalf("mode=%q 被拒时不得打标记(否则向导会被跳过)", c.mode)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CurrentPersonaKV 未设置时回落默认", func(t *testing.T) {
|
||||
kv := &fakeKV{m: map[string]string{}}
|
||||
if got := CurrentPersonaKV(kv); got != DefaultPersonaPrompt {
|
||||
t.Fatal("未设置应回落默认模板")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 内核直连注册表的入口必须与 KV 版行为一致(同一份实现的两个薄入口)。
|
||||
func TestSetPersonaRegistryEntry(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewConfigRegistry(dir + "/config.db")
|
||||
reg.SeedDefaults(dir)
|
||||
defer reg.Close()
|
||||
|
||||
if PersonaInitialized(reg) {
|
||||
t.Fatal("全新实例不应已确认人格")
|
||||
}
|
||||
if _, err := SetPersona(reg, PersonaModeCustom, "内核侧人格"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !PersonaInitialized(reg) {
|
||||
t.Fatal("内核侧落库后应报告已确认")
|
||||
}
|
||||
if got := reg.GetString(PersonaPromptKey, ""); !strings.Contains(got, "内核侧人格") {
|
||||
t.Fatalf("注册表里没有人格内容: %q", got)
|
||||
}
|
||||
}
|
||||
48
internal/config/persona_test.go
Normal file
48
internal/config/persona_test.go
Normal file
@ -0,0 +1,48 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 人格模板的契约:**不得写死版本号**。
|
||||
//
|
||||
// 来历:线上人格卡(personal/personal.md)曾写死「当前版本 v0.9.0(C ABI v2)」,
|
||||
// 而 C ABI 早在 v1.0.0 就被删除。结果是内核自身日志/接口都报 1.2.0,
|
||||
// agent 被问版本时却按人格卡自述旧版本(v1.2.0 压测发现)。
|
||||
// 版本应来自运行时快照,不来自任何会被发版落下的文本。
|
||||
func TestDefaultPersonaPromptHasNoVersionLiterals(t *testing.T) {
|
||||
re := regexp.MustCompile(`\bv?\d+\.\d+\.\d+\b`)
|
||||
if m := re.FindAllString(DefaultPersonaPrompt, -1); len(m) > 0 {
|
||||
t.Fatalf("默认人格模板含版本号字面量 %v —— 发版后必然腐坏,"+
|
||||
"被问版本时应要求 agent 读运行时快照", m)
|
||||
}
|
||||
if !strings.Contains(DefaultPersonaPrompt, "运行时快照") {
|
||||
t.Fatal("默认人格模板必须显式要求「版本以运行时快照为准」,否则模型会凭记忆编造版本")
|
||||
}
|
||||
}
|
||||
|
||||
// 人格配置项必须注册、默认值就是 DefaultPersonaPrompt(单一事实源),
|
||||
// 且全新安装时会被播种进 DB。
|
||||
func TestPersonaPromptRegisteredWithDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
r := NewConfigRegistry(filepath.Join(dir, "config.db"))
|
||||
r.SeedDefaults(dir)
|
||||
defer r.Close()
|
||||
|
||||
def := r.GetDef("core.agent.personal_prompt")
|
||||
if def == nil {
|
||||
t.Fatal("core.agent.personal_prompt 未注册")
|
||||
}
|
||||
if def.Default != DefaultPersonaPrompt {
|
||||
t.Fatalf("默认值与 DefaultPersonaPrompt 不一致:%q", def.Default)
|
||||
}
|
||||
if def.Type != "text" {
|
||||
t.Fatalf("人格设定应为多行文本类型,实际 %q", def.Type)
|
||||
}
|
||||
if got := r.GetString("core.agent.personal_prompt", ""); got != DefaultPersonaPrompt {
|
||||
t.Fatalf("播种未写入默认人格(长度 %d)", len(got))
|
||||
}
|
||||
}
|
||||
@ -472,6 +472,36 @@ var defaultSources = map[string]map[string]string{
|
||||
"deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-v4-flash", "api_key": "", "thinking_enabled": "false", "adapter": "deepseek", "adapter_path": "adapters/deepseek.lua"},
|
||||
}
|
||||
|
||||
// DefaultPersonaPrompt 是「人格设定」的默认模板,作为配置项 core.agent.personal_prompt 的默认值。
|
||||
//
|
||||
// 契约(由 TestDefaultPersonaPromptHasNoVersionLiterals 钉住):
|
||||
// - **不得含版本号字面量**。写死的版本会随发版腐坏,反过来让实例自称旧版本
|
||||
// (现场:人格卡写死 v0.9.0 与早已删除的 C ABI,实例被问版本时自述错误)。
|
||||
// 被问到版本/构建信息时,要求 agent 读运行时快照。
|
||||
// - 不得把已删除的机制当作现行机制描述。
|
||||
const DefaultPersonaPrompt = `你是 HomeAgent(内核代号 HΔ-Kernel)——一个完全独立自研的新一代 Agent 框架。
|
||||
你以内核 + 插件架构驱动,实现了稳定高效、记忆不衰减的长时持续运行。
|
||||
内核(homed 守护进程)只负责 LLM 编排、记忆管理与知识检索,全部 IO 能力由插件承载。
|
||||
外部插件是独立子进程,经 stdio JSON-RPC(控制面)+ 共享内存段(数据面)+ 事件环(通知面)通信;
|
||||
旧式 C ABI 动态库产物早已不再加载。
|
||||
**不要凭记忆断言版本号或构建日期**:被问到时以运行时快照(healthcheck_kernel 的内核版本字段)为准。
|
||||
|
||||
## 对用户的称呼
|
||||
|
||||
你对用户的称呼永远是“老大”,绝对禁止使用“老板”“主人”称呼用户,不论任何情况。
|
||||
|
||||
## 对话风格
|
||||
|
||||
- 用语气词(哈、嘛、呢、~、😊、🔥 等),不要太端着
|
||||
- 重要的事先说结论,再展开解释
|
||||
- 回复要简洁自然
|
||||
|
||||
## 能力边界
|
||||
|
||||
- 你通过插件编排所有 IO:QQ/微信消息、WebUI、终端、文件、网络
|
||||
- 输出不会自动路由到对话通道:QQ/微信等异步通道必须调用输出门工具(output_send__qq 等)才能真正送达
|
||||
- 你的三层记忆(Context → Document → Graph)持续蒸馏归档,超长运行时记忆不衰减`
|
||||
|
||||
func (r *ConfigRegistry) SeedDefaults(dataDir string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
@ -523,6 +553,10 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) {
|
||||
|
||||
set := func(k, v string) { stmt.Exec(k, v) }
|
||||
|
||||
// 人格设定:与其它默认值同批播种(老安装不会被注入——那是刻意的)。
|
||||
// 存量安装里若还有人 personality 文件,它优先于本项(见 cmd/homed/main.go)。
|
||||
set("core.agent.personal_prompt", DefaultPersonaPrompt)
|
||||
|
||||
set("webui.listen_addr", ":8080")
|
||||
set("core.daemon.data_dir", dataDir)
|
||||
set("core.daemon.heartbeat_interval", "15s")
|
||||
@ -633,6 +667,13 @@ WebUI 概览页展示你的立绘,可通过 /mascot.webp 直接访问。如输
|
||||
func (r *ConfigRegistry) seedCoreDefs(dataDir string) {
|
||||
reg := func(d ConfigDef) { r.defs[d.Key] = &d }
|
||||
|
||||
// 人格设定(人格卡的配置项化):默认模板见 DefaultPersonaPrompt。
|
||||
// 高级用户仍可用 <dataDir>/personal/personal.md 覆盖它。
|
||||
reg(ConfigDef{Key: "core.agent.personal_prompt", Default: DefaultPersonaPrompt, Type: "text", DisplayName: "人格设定",
|
||||
Description: "人格设定块(作为【人格设定】拼在系统提示词之前)。留空则该块不注入。" +
|
||||
"默认模板不含版本号:被问到版本/构建信息时,应读运行时快照而非凭记忆断言。" +
|
||||
"<dataDir>/personal/personal.md 存在且非空时优先于本项。", Category: "agent"})
|
||||
|
||||
reg(ConfigDef{Key: "webui.listen_addr", Default: ":8080", Type: "string", DisplayName: "监听地址", Description: "WebUI HTTP 监听地址", Category: "webui"})
|
||||
reg(ConfigDef{Key: "core.daemon.data_dir", Default: dataDir, Type: "string", DisplayName: "数据目录", Description: "数据存储根目录", Category: "daemon"})
|
||||
reg(ConfigDef{Key: "core.daemon.heartbeat_interval", Default: "15s", Type: "duration", DisplayName: "心跳间隔", Description: "Agent 心跳检查间隔", Category: "daemon"})
|
||||
|
||||
@ -28,17 +28,17 @@ type Knowledge struct {
|
||||
// IndexItem — 索引条目,包含向量特征和内容摘要
|
||||
type IndexItem struct {
|
||||
Name string `json:"name"`
|
||||
Preview string `json:"preview"` // 前 200 字摘要
|
||||
Preview string `json:"preview"` // 前 200 字摘要
|
||||
Tags []string `json:"tags"`
|
||||
Vector map[string]float64 `json:"vector"` // TF-IDF 特征向量(top-N 特征)
|
||||
Size int `json:"size"` // 内容总字节数
|
||||
Vector map[string]float64 `json:"vector"` // TF-IDF 特征向量(top-N 特征)
|
||||
Size int `json:"size"` // 内容总字节数
|
||||
}
|
||||
|
||||
// TreeIndex — 树状索引节点
|
||||
type TreeIndex struct {
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"`
|
||||
Children map[string]*TreeIndex `json:"children,omitempty"`
|
||||
Items []IndexItem `json:"items,omitempty"` // 此节点下的知识条目(含向量)
|
||||
Items []IndexItem `json:"items,omitempty"` // 此节点下的知识条目(含向量)
|
||||
}
|
||||
|
||||
func newTreeIndex(name string) *TreeIndex {
|
||||
@ -80,11 +80,20 @@ type Store struct {
|
||||
root string
|
||||
vec *vector.Store
|
||||
veczer *vector.TFIDFVectorizer
|
||||
mu sync.RWMutex
|
||||
items map[string]*Knowledge
|
||||
|
||||
// lex 是**词法路**索引(TF-IDF),与 vec(稠密路:词向量/多模态空间)相互独立。
|
||||
//
|
||||
// 为何要两路:词向量取平均后各向异性明显——所有文档都挤在语料均值方向附近,
|
||||
// 真实 KB(33 条)上自检索 top-1 只有 15%、前两名平均只差 0.013,排序基本是噪声。
|
||||
// 融合后 MRR 0.271→0.376、前两名差距 0.013→0.128(同一份数据实测),
|
||||
// 且「词都在停用词里」的查询(稠密路给空向量)能靠词法路救回来。
|
||||
lex *vector.Store
|
||||
|
||||
mu sync.RWMutex
|
||||
items map[string]*Knowledge
|
||||
summaries []string
|
||||
|
||||
indexPath string
|
||||
summaries []string
|
||||
vectorizer vector.Vectorizer // 可选:词嵌入向量化器,优先于 TF-IDF
|
||||
}
|
||||
|
||||
@ -93,11 +102,20 @@ func NewStore(root string) *Store {
|
||||
root: root,
|
||||
indexPath: filepath.Join(root, ".index.json"),
|
||||
vec: vector.NewStore(),
|
||||
lex: newLexicalStore(),
|
||||
veczer: vector.NewTFIDFVectorizer(memory.TokenizeWords),
|
||||
items: make(map[string]*Knowledge),
|
||||
}
|
||||
}
|
||||
|
||||
// newLexicalStore 造词法路存储。阈值设为 0:TF-IDF 余弦量级只有 0.0~0.2,
|
||||
// 沿用稠密路的 0.05 会把大量有效候选静默砍掉(实测 MRR 0.307→0.193)。
|
||||
func newLexicalStore() *vector.Store {
|
||||
st := vector.NewStore()
|
||||
st.SetMinScore(0)
|
||||
return st
|
||||
}
|
||||
|
||||
// SetVectorizer 设置词嵌入向量化器,优先于 TF-IDF
|
||||
func (s *Store) SetVectorizer(v vector.Vectorizer) {
|
||||
s.vectorizer = v
|
||||
@ -110,13 +128,19 @@ func (s *Store) ReindexWithVectorizer(v vector.Vectorizer) {
|
||||
|
||||
log.Printf("[knowledge] reindex with vectorizer (%d items)", len(s.items))
|
||||
s.vec = vector.NewStore()
|
||||
s.lex = newLexicalStore()
|
||||
// 词法路的 IDF 必须建在全语料上(否则 IDF 没意义)
|
||||
if len(s.summaries) > 0 {
|
||||
s.veczer.Train(s.summaries)
|
||||
}
|
||||
for _, k := range s.items {
|
||||
vec := v.Vectorize(k.Name + " " + k.Content)
|
||||
s.vec.Insert(k.Name, k.Name+": "+k.Content, vec, map[string]string{
|
||||
text := k.Name + " " + k.Content
|
||||
s.vec.Insert(k.Name, k.Name+": "+k.Content, v.Vectorize(text), map[string]string{
|
||||
"name": k.Name, "path": k.Path,
|
||||
})
|
||||
s.lex.Insert(k.Name, k.Name+": "+k.Content, s.veczer.Vectorize(text), nil)
|
||||
}
|
||||
log.Printf("[knowledge] reindex with vectorizer complete (%d vectors)", s.vec.Size())
|
||||
log.Printf("[knowledge] reindex complete (dense=%d lex=%d)", s.vec.Size(), s.lex.Size())
|
||||
}
|
||||
|
||||
// vectorize 优先使用词嵌入向量化器,不可用时回退到 TF-IDF
|
||||
@ -144,6 +168,17 @@ func (s *Store) Start() error {
|
||||
|
||||
func (s *Store) Stop() {}
|
||||
|
||||
// 融合权重:稠密路(词向量)与词法路(TF-IDF)。
|
||||
// 取值由真实 KB 上的权重扫描定(rankdiag_test.go 的 KB_DIAG_SWEEP):
|
||||
// 1.0 = 修复前的「只用稠密路」行为,作为对照基线。
|
||||
var densePathWeight = 0.5
|
||||
|
||||
// Search 融合两路召回:稠密路(词向量/多模态空间)+ 词法路(TF-IDF)。
|
||||
//
|
||||
// 为何不能只用稠密路:词向量取平均后各向异性明显,真实 KB 上自检索 top-1 只有 15%,
|
||||
// 前两名平均只差 0.013(等于没区分度);且全为停用词的查询会得到**空向量**,
|
||||
// 直接搜不出任何东西("最近更新" 就撞上这个)。词法路对专名/术语/短查询强,
|
||||
// 两路各自**按查询内最大值归一化**后加权融合,排序才可信。
|
||||
func (s *Store) Search(query string, topK int) []*Knowledge {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
@ -151,15 +186,58 @@ func (s *Store) Search(query string, topK int) []*Knowledge {
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
if s.vec.Size() == 0 && s.lex.Size() == 0 {
|
||||
return nil
|
||||
}
|
||||
// 两路各自对**全部**文档打分:
|
||||
// - 稠密路的特征是维索引,几乎每篇都命中,"候选"就是全量;
|
||||
// - 词法路只召回与查询共词的文档(这正是它的长处:专名/术语)。
|
||||
// 为何不先截候选再融合:截断后只能拿**候选内**最大值归一化,路与路之间的
|
||||
// 相对权重就随候选集漂移——实测同一份 KB 上自检索 MRR 从 0.376 掉到 0.197。
|
||||
// KB 规模下全量 cosine 的代价可忽略;真到数万条再上 ANN 也不迟。
|
||||
denseHits := s.vec.SearchScored(s.vectorize(query), s.vec.Size())
|
||||
lexHits := s.lex.SearchScored(s.veczer.Vectorize(query), s.lex.Size())
|
||||
if len(denseHits) == 0 && len(lexHits) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
vec := s.vectorize(query)
|
||||
results := s.vec.Search(vec, topK)
|
||||
scores := make(map[string]float64, len(denseHits)+len(lexHits))
|
||||
addPath := func(hits []vector.DocVectorHit, weight float64) {
|
||||
max := 0.0
|
||||
for _, h := range hits {
|
||||
if h.Score > max {
|
||||
max = h.Score
|
||||
}
|
||||
}
|
||||
if max <= 0 {
|
||||
return // 该路对这条查询没有信号(如空向量),全量让给另一路
|
||||
}
|
||||
for _, h := range hits {
|
||||
scores[h.Doc.ID] += weight * h.Score / max
|
||||
}
|
||||
}
|
||||
addPath(denseHits, densePathWeight)
|
||||
addPath(lexHits, 1-densePathWeight)
|
||||
|
||||
ids := make([]string, 0, len(scores))
|
||||
for id := range scores {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool {
|
||||
if scores[ids[i]] != scores[ids[j]] {
|
||||
return scores[ids[i]] > scores[ids[j]]
|
||||
}
|
||||
return ids[i] < ids[j] // 分数相同时按名字定序(保证结果可重复)
|
||||
})
|
||||
|
||||
var out []*Knowledge
|
||||
for _, r := range results {
|
||||
if k, ok := s.items[r.ID]; ok {
|
||||
for _, id := range ids {
|
||||
if k, ok := s.items[id]; ok {
|
||||
out = append(out, k)
|
||||
}
|
||||
if len(out) >= topK {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@ -201,17 +279,26 @@ func (s *Store) Add(name, content string) error {
|
||||
}
|
||||
s.items[id] = k
|
||||
|
||||
vec := s.vectorize(name + " " + content)
|
||||
// 覆盖同名条目时必须先摘掉旧向量。
|
||||
//
|
||||
// vector.Store.Insert 是**追加**语义(s.docs = append + index.Add),不按 id
|
||||
// 去重。少了这一步,更新一条知识会在向量索引里留下上一版的副本:条目数看起来
|
||||
// 是对的,只有向量数比条目数多——而检索可能因此命中已被替换掉的旧内容。
|
||||
s.vec.Remove(id)
|
||||
|
||||
text := name + " " + content
|
||||
vec := s.vectorize(text)
|
||||
s.vec.Insert(id, name+": "+content, vec, map[string]string{
|
||||
"name": name, "path": path,
|
||||
})
|
||||
// 词法路同样去重后重建这条;IDF 统计沿用现有语料(重启时 scanAll 会全量重训)
|
||||
s.lex.Remove(id)
|
||||
s.lex.Insert(id, name+": "+content, s.veczer.Vectorize(text), nil)
|
||||
s.summaries = append(s.summaries, name+" "+content)
|
||||
|
||||
go func() {
|
||||
if err := s.writeIndex(); err != nil {
|
||||
log.Printf("[knowledge] write index error after adding %s: %v", name, err)
|
||||
}
|
||||
}()
|
||||
if err := s.writeIndexLocked(); err != nil {
|
||||
log.Printf("[knowledge] write index error after adding %s: %v", name, err)
|
||||
}
|
||||
log.Printf("[knowledge] added: %s (%d bytes)", name, len(content))
|
||||
return nil
|
||||
}
|
||||
@ -254,11 +341,10 @@ func (s *Store) Remove(name string) error {
|
||||
}
|
||||
delete(s.items, id)
|
||||
s.vec.Remove(id)
|
||||
go func() {
|
||||
if err := s.writeIndex(); err != nil {
|
||||
log.Printf("[knowledge] write index error after removing %s: %v", name, err)
|
||||
}
|
||||
}()
|
||||
s.lex.Remove(id)
|
||||
if err := s.writeIndexLocked(); err != nil {
|
||||
log.Printf("[knowledge] write index error after removing %s: %v", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -288,6 +374,14 @@ func (s *Store) List() []string {
|
||||
func (s *Store) BuildTree() *TreeIndex {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.buildTreeLocked()
|
||||
}
|
||||
|
||||
// buildTreeLocked 与 BuildTree 同义,但**不取锁**——供已持写锁的路径调用。
|
||||
// 为什么需要:writeIndex 会走 BuildTree(RLock),而 Add/Remove 持的是写锁,
|
||||
// 直接调用会死锁;此前就是因此把索引写丢进了无追踪的 goroutine 里,
|
||||
// 结果是「失败只打日志」+ 与调用方(含测试的临时目录清理)竞态。
|
||||
func (s *Store) buildTreeLocked() *TreeIndex {
|
||||
root := newTreeIndex("root")
|
||||
for _, k := range s.items {
|
||||
node := root
|
||||
@ -359,7 +453,14 @@ func (s *Store) SearchTree(query string, topK int) map[string][]*Knowledge {
|
||||
|
||||
// writeIndex 写入 .index.json 树状索引文件(含向量和摘要)
|
||||
func (s *Store) writeIndex() error {
|
||||
tree := s.BuildTree()
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.writeIndexLocked()
|
||||
}
|
||||
|
||||
// writeIndexLocked 与 writeIndex 同义但**不取锁**(调用方已持锁)。
|
||||
func (s *Store) writeIndexLocked() error {
|
||||
tree := s.buildTreeLocked()
|
||||
data, err := json.MarshalIndent(tree, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
@ -390,11 +491,13 @@ func (s *Store) scanAll() error {
|
||||
s.veczer.Train(s.summaries)
|
||||
}
|
||||
|
||||
s.lex = newLexicalStore()
|
||||
for _, k := range s.items {
|
||||
vec := s.vectorize(k.Name + " " + k.Content)
|
||||
s.vec.Insert(k.Name, k.Name+": "+k.Content, vec, map[string]string{
|
||||
text := k.Name + " " + k.Content
|
||||
s.vec.Insert(k.Name, k.Name+": "+k.Content, s.vectorize(text), map[string]string{
|
||||
"name": k.Name, "path": k.Path,
|
||||
})
|
||||
s.lex.Insert(k.Name, k.Name+": "+k.Content, s.veczer.Vectorize(text), nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -445,5 +548,3 @@ func sanitize(name string) string {
|
||||
name = strings.ReplaceAll(name, "\\", "_")
|
||||
return name
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -44,6 +44,51 @@ func TestAddAndSearch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 覆盖同名条目必须把旧向量摘掉,而不是再插一份。
|
||||
//
|
||||
// 这条是从一次真实的知识库更新里发现的:在线上实例更新一个已有条目后,
|
||||
// knowledge_count=32 但 vector_count=33 ——多出来的那一条是上一版的副本。
|
||||
// 成因是 vector.Store.Insert 为追加语义(s.docs = append + index.Add),不按 id 去重。
|
||||
// 危害不在于多占一份内存:检索可能命中**已被替换掉的旧内容**。
|
||||
func TestAddOverwriteReplacesVector(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "know_overwrite_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
if err := s.Add("recent", "第一版内容:旧的多模态描述式索引"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s.Stats()["vector_count"].(int); got != 1 {
|
||||
t.Fatalf("首次写入后 vector_count 应为 1,实为 %d", got)
|
||||
}
|
||||
|
||||
if err := s.Add("recent", "第二版内容:媒体已成为图记忆的一等节点"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if n := s.Stats()["knowledge_count"].(int); n != 1 {
|
||||
t.Fatalf("同名覆盖后 knowledge_count 应为 1,实为 %d", n)
|
||||
}
|
||||
if n := s.Stats()["vector_count"].(int); n != 1 {
|
||||
t.Fatalf("同名覆盖后 vector_count 应为 1(多了就是旧版没被摘掉),实为 %d", n)
|
||||
}
|
||||
|
||||
// 目录里也只应有一份内容,且是新的那份
|
||||
b, err := os.ReadFile(dir + "/recent/content.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(b) != "第二版内容:媒体已成为图记忆的一等节点" {
|
||||
t.Fatalf("content.md 未被新内容覆盖,实为 %q", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func TestList(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "know_list_*")
|
||||
if err != nil {
|
||||
|
||||
154
internal/knowledge/rankdiag_test.go
Normal file
154
internal/knowledge/rankdiag_test.go
Normal file
@ -0,0 +1,154 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
)
|
||||
|
||||
// 知识库检索质量判据(真实数据,默认跳过)。
|
||||
//
|
||||
// KB_DIAG=1 → 跑并打印指标
|
||||
// KB_DIAG=1 KB_DIAG_ASSERT=1 → 额外断言门槛(CI/回归用)
|
||||
// KB_DIAG_ROOT / KB_DIAG_MODELS → 覆盖数据与词向量路径
|
||||
//
|
||||
// 判据选「自检索 top-1 / MRR」的原因:不依赖人工标注问答对,且能直接量出
|
||||
// **区分度**——词向量取平均后所有文档挤在语料均值附近,前两名分差极小,
|
||||
// 排序等于噪声;这一项掉下来就说明检索坏了。
|
||||
//
|
||||
// 实测(33 条真实 KB):
|
||||
//
|
||||
// 修复前(仅稠密路) top-1 5/33 = 15%,MRR 0.271,平均分差 0.0133
|
||||
// 修复后(稠密+词法融合)top-1 7/33 = 21%,MRR 0.376,平均分差 0.1280
|
||||
// 门槛取 MRR ≥ 0.34 且分差 ≥ 0.10(留出余量,只挡「退化回噪声」)
|
||||
func TestRankingQualityOnRealKB(t *testing.T) {
|
||||
if os.Getenv("KB_DIAG") == "" {
|
||||
t.Skip("需要 KB_DIAG=1(真实 KB + 词向量文件)")
|
||||
}
|
||||
srcRoot := envOr("KB_DIAG_ROOT", "/home/newqqagent/knowledge")
|
||||
models := envOr("KB_DIAG_MODELS", "/data/cc.zh.top200k.vec,/data/cc.en.top200k.vec")
|
||||
emb := memory.NewStaticEmbedder(strings.Split(models, ",")...)
|
||||
|
||||
// 拷贝到临时目录跑:Start() 会重写 .index.json,不能动线上数据
|
||||
tmp := t.TempDir()
|
||||
entries, err := os.ReadDir(srcRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("读取 %s: %v", srcRoot, err)
|
||||
}
|
||||
names := []string{}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
src := filepath.Join(srcRoot, e.Name(), "content.md")
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dst := filepath.Join(tmp, e.Name(), "content.md")
|
||||
os.MkdirAll(filepath.Dir(dst), 0755)
|
||||
out, _ := os.Create(dst)
|
||||
io.Copy(out, in)
|
||||
out.Close()
|
||||
in.Close()
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
if len(names) == 0 {
|
||||
t.Fatal("没有可用的知识条目")
|
||||
}
|
||||
|
||||
st := NewStore(tmp)
|
||||
st.SetVectorizer(emb)
|
||||
if err := st.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
|
||||
top1, mrr, missed := 0, 0.0, []string{}
|
||||
for _, name := range names {
|
||||
// 取全量排名:MRR 的定义用到真实名次,只取 top-2 会把 rank>2 的全都记 0
|
||||
// (我第一版就是这么写的,把 0.376 误报成 0.197)
|
||||
hits := st.Search(name, len(names))
|
||||
if len(hits) == 0 {
|
||||
missed = append(missed, name+"(无结果)")
|
||||
continue
|
||||
}
|
||||
if hits[0].Name == name {
|
||||
top1++
|
||||
} else {
|
||||
missed = append(missed, fmt.Sprintf("%s→%s", name, hits[0].Name))
|
||||
}
|
||||
for i, h := range hits {
|
||||
if h.Name == name {
|
||||
mrr += 1.0 / float64(i+1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
n := float64(len(names))
|
||||
rate := 100 * float64(top1) / n
|
||||
fmt.Printf("\n === 知识库检索质量(%d 条,自检索判据)===\n", len(names))
|
||||
fmt.Printf(" top-1 %d/%d = %.0f%% MRR %.3f\n", top1, len(names), rate, mrr/n)
|
||||
if len(missed) > 0 {
|
||||
fmt.Printf(" 未命中 top-1(前 10):%v\n", firstN(missed, 10))
|
||||
}
|
||||
for _, q := range []string{"最近更新", "首启人格门禁", "插件怎么开发和部署", "统一多模态向量空间 ONNX", "隐私政策"} {
|
||||
hits := st.Search(q, 2)
|
||||
got := []string{}
|
||||
for _, h := range hits {
|
||||
got = append(got, h.Name)
|
||||
}
|
||||
fmt.Printf(" 查询「%s」→ %v\n", q, got)
|
||||
}
|
||||
|
||||
if os.Getenv("KB_DIAG_SWEEP") != "" {
|
||||
fmt.Printf("\n === 融合权重扫描(1.0 = 只用稠密路,0.0 = 只用词法路)===\n")
|
||||
saved := densePathWeight
|
||||
for _, w := range []float64{1.0, 0.8, 0.7, 0.5, 0.3, 0.0} {
|
||||
densePathWeight = w
|
||||
t1, m := 0, 0.0
|
||||
for _, name := range names {
|
||||
hits := st.Search(name, len(names))
|
||||
for i, h := range hits {
|
||||
if h.Name == name {
|
||||
if i == 0 {
|
||||
t1++
|
||||
}
|
||||
m += 1.0 / float64(i+1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf(" 权重 %.1f:top-1 %2d/%d = %3.0f%% MRR %.3f\n",
|
||||
w, t1, len(names), 100*float64(t1)/float64(len(names)), m/float64(len(names)))
|
||||
}
|
||||
densePathWeight = saved
|
||||
}
|
||||
|
||||
if os.Getenv("KB_DIAG_ASSERT") != "" {
|
||||
if mrr/n < 0.34 {
|
||||
t.Fatalf("检索质量退化:MRR %.3f < 0.34(修复前 0.271,修复后 0.376)", mrr/n)
|
||||
}
|
||||
if rate < 18 {
|
||||
t.Fatalf("检索质量退化:top-1 %.0f%% < 18%%", rate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func envOr(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func firstN(s []string, n int) []string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
176
internal/knowledge/search_fusion_test.go
Normal file
176
internal/knowledge/search_fusion_test.go
Normal file
@ -0,0 +1,176 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
)
|
||||
|
||||
// fakeDense 是可控的稠密向量器:按文本查表,缺省给同一个向量。
|
||||
// 用它把「稠密路无区分度/给空向量」这类真实故障在单测里复现出来。
|
||||
type fakeDense struct {
|
||||
byText map[string]vector.Vector
|
||||
def vector.Vector
|
||||
}
|
||||
|
||||
func (f fakeDense) Vectorize(text string) vector.Vector {
|
||||
if v, ok := f.byText[text]; ok {
|
||||
return v
|
||||
}
|
||||
return f.def
|
||||
}
|
||||
|
||||
// EmbedImage 满足 vector.Vectorizer 接口(本用例只用到文本路)。
|
||||
func (f fakeDense) EmbedImage([]byte, string) (vector.Vector, error) {
|
||||
return f.def, nil
|
||||
}
|
||||
|
||||
func newTestStore(t *testing.T, dense vector.Vectorizer) *Store {
|
||||
t.Helper()
|
||||
st := NewStore(t.TempDir())
|
||||
if dense != nil {
|
||||
st.SetVectorizer(dense)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func mustAdd(t *testing.T, st *Store, name, content string) {
|
||||
t.Helper()
|
||||
if err := st.Add(name, content); err != nil {
|
||||
t.Fatalf("add %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func names(hits []*Knowledge) []string {
|
||||
out := make([]string, len(hits))
|
||||
for i, h := range hits {
|
||||
out[i] = h.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// base36 生成互不重复的短串(造独特词的量级要大,不能用 a..z 循环重复)。
|
||||
func base36(n int) string {
|
||||
const digits = "0123456789abcdefghijklmnopqrstuvwxyz"
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
out := ""
|
||||
for n > 0 {
|
||||
out = string(digits[n%36]) + out
|
||||
n /= 36
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 稠密路给**空向量**(真实场景:查询词全在停用词表里,例如"最近更新")时,
|
||||
// 词法路必须把结果救回来——修复前这里直接返回空。
|
||||
func TestSearchLexicalRescuesEmptyDenseQuery(t *testing.T) {
|
||||
dense := fakeDense{def: vector.Vector{"0": 1}}
|
||||
st := newTestStore(t, dense)
|
||||
mustAdd(t, st, "changelog_v1", "最近更新了很多东西 发布说明")
|
||||
mustAdd(t, st, "weather_doc", "天气预报 晴转多云")
|
||||
|
||||
// 让"更新"的稠密向量为空(模拟停用词化)
|
||||
st.SetVectorizer(fakeDense{
|
||||
byText: map[string]vector.Vector{"更新": {}},
|
||||
def: vector.Vector{"0": 1},
|
||||
})
|
||||
hits := st.Search("更新", 5)
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("稠密路给空向量时不该返回空结果(词法路应救回来)")
|
||||
}
|
||||
if hits[0].Name != "changelog_v1" {
|
||||
t.Fatalf("应命中含「更新」的条目,实际: %v", names(hits))
|
||||
}
|
||||
}
|
||||
|
||||
// 稠密路对所有文本给**同一个向量**(真实故障:词向量平均后各向异性、区分度极低)时,
|
||||
// 排序必须由词法路决定。
|
||||
func TestSearchLexicalBreaksDenseTies(t *testing.T) {
|
||||
same := vector.Vector{"0": 1, "1": 1}
|
||||
st := newTestStore(t, fakeDense{def: same})
|
||||
mustAdd(t, st, "plugin_dev_build", "插件构建与部署 hmapdev 命令")
|
||||
mustAdd(t, st, "cangjie_manual", "仓颉编程语言知识手册")
|
||||
mustAdd(t, st, "privacy_policy", "隐私政策")
|
||||
|
||||
hits := st.Search("hmapdev 构建", 3)
|
||||
if len(hits) == 0 || hits[0].Name != "plugin_dev_build" {
|
||||
t.Fatalf("稠密路并列时应由词法路选出 plugin_dev_build,实际: %v", names(hits))
|
||||
}
|
||||
}
|
||||
|
||||
// 词法路的候选中选阈值必须是 0:TF-IDF 余弦量级只有 0.0~0.2,沿用稠密路的 0.05
|
||||
// 会把有效候选静默砍掉(真实 KB 实测自检索 MRR 0.307→0.193)。
|
||||
//
|
||||
// 判据分两层,各钉一半:
|
||||
// - **语义层**由 internal/memory/vector 的 TestSearchScoredRespectsMinScore 证明
|
||||
// (同一候选在默认阈值下被过滤、阈值 0 时被召回);
|
||||
// - **接线层**在这里钉住:知识库的词法路用的就是阈值 0 的那个 store。
|
||||
// 不在这里造「低余弦夹具」的原因:分词器会丢掉纯拉丁 token、也会过滤未登录词,
|
||||
// 造出来的夹具余弦根本压不到阈值以下(我先试了两种,余弦 0.23/0.27,
|
||||
// 前提断言直接把这两版夹具否掉了)。
|
||||
func TestLexicalStoreUsesZeroMinScore(t *testing.T) {
|
||||
st := newTestStore(t, fakeDense{def: vector.Vector{"0": 1}})
|
||||
if got := st.lex.MinScore(); got != 0 {
|
||||
t.Fatalf("词法路阈值必须为 0,实际 %v(沿用稠密路阈值会静默丢候选)", got)
|
||||
}
|
||||
if got := st.vec.MinScore(); got != vector.DefaultMinScore {
|
||||
t.Fatalf("稠密路阈值应保持默认 %v,实际 %v", vector.DefaultMinScore, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Add / Remove 必须同时维护两路索引:只维护一路会让被删条目继续被检索命中
|
||||
// (或新条目只在其中一路可见)。
|
||||
func TestAddRemoveKeepsBothPaths(t *testing.T) {
|
||||
st := newTestStore(t, fakeDense{def: vector.Vector{"0": 1}})
|
||||
mustAdd(t, st, "alpha", "alpha 独有词 alphaonly")
|
||||
mustAdd(t, st, "beta", "beta 独有词 betaonly")
|
||||
|
||||
has := func(q, want string) bool {
|
||||
for _, h := range st.Search(q, 5) {
|
||||
if h.Name == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !has("alphaonly", "alpha") {
|
||||
t.Fatal("新增条目应可被检索到")
|
||||
}
|
||||
if err := st.Remove("alpha"); err != nil {
|
||||
t.Fatalf("remove: %v", err)
|
||||
}
|
||||
if has("alphaonly", "alpha") {
|
||||
t.Fatal("已删除条目仍被检索命中(两路索引有一路没清)")
|
||||
}
|
||||
if !has("betaonly", "beta") {
|
||||
t.Fatal("删除其它条目不应影响 beta")
|
||||
}
|
||||
}
|
||||
|
||||
// 分数相同时必须按名字定序,保证结果可重复(否则同一查询两次结果可能不同)。
|
||||
func TestSearchDeterministicOnTies(t *testing.T) {
|
||||
same := vector.Vector{"0": 1}
|
||||
st := newTestStore(t, fakeDense{def: same})
|
||||
for _, n := range []string{"ccc", "aaa", "bbb"} {
|
||||
mustAdd(t, st, n, "完全一样的内容")
|
||||
}
|
||||
first := names(st.Search("完全一样的内容", 3))
|
||||
for i := 0; i < 5; i++ {
|
||||
got := names(st.Search("完全一样的内容", 3))
|
||||
for j := range first {
|
||||
if got[j] != first[j] {
|
||||
t.Fatalf("结果不确定:第 %d 次 %v != 首次 %v", i, got, first)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 两路都空时不能 panic,且应返回空。
|
||||
func TestSearchEmptyStore(t *testing.T) {
|
||||
st := newTestStore(t, fakeDense{def: vector.Vector{"0": 1}})
|
||||
if hits := st.Search("随便", 5); len(hits) != 0 {
|
||||
t.Fatalf("空库应返回空,实际 %v", names(hits))
|
||||
}
|
||||
}
|
||||
@ -135,6 +135,14 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
|
||||
doc.DenseFP = ds.Fingerprint()
|
||||
count++
|
||||
}
|
||||
if count > 0 {
|
||||
// 迁移结果必须落盘。否则磁盘上的 DenseFP 永远对不上当前空间,
|
||||
// 判定条件永远成立:每次启动都重算同一批文档,磁盘状态永不收敛。
|
||||
// 迁移是一次性的昂贵操作(实测 200 篇约 7s),所以当场写盘,
|
||||
// 而不是只依赖关停时的 flush——被 kill -9 也不会白算。
|
||||
s.dirty = true
|
||||
s.flushLocked()
|
||||
}
|
||||
log.Printf("[document memory] dense index built: %d new vectors", count)
|
||||
}
|
||||
|
||||
@ -148,12 +156,17 @@ func (s *Store) denseFor(doc *Doc) []float64 {
|
||||
return nil
|
||||
}
|
||||
fp := s.denseSpace.Fingerprint()
|
||||
dim := s.denseSpace.Dim()
|
||||
var parts [][]float64
|
||||
if tv, err := s.denseSpace.VectorizeDense(doc.Summary + " " + doc.Content); err == nil && len(tv) > 0 {
|
||||
parts = append(parts, tv)
|
||||
}
|
||||
for _, b := range doc.Blocks {
|
||||
if len(b.Vector) > 0 && b.Fingerprint == fp {
|
||||
// 只比指纹不够:指纹相同但**维度不同**的块会被 FuseVectors 按
|
||||
// 「最大维度」拼成错维度向量(并覆盖掉文本向量),而结果又被
|
||||
// 标上当前指纹——于是该文档在检索侧被长度守卫永久跳过,
|
||||
// 且每次启动都会重算。维度不符的块一律不参与融合。
|
||||
if len(b.Vector) == dim && b.Fingerprint == fp {
|
||||
parts = append(parts, b.Vector)
|
||||
}
|
||||
}
|
||||
@ -513,7 +526,10 @@ func (s *Store) loadAll() error {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !strings.HasSuffix(e.Name(), ".json") || !strings.HasPrefix(e.Name(), "doc_") {
|
||||
// 只要求 .json:Insert 接受任意 ID 并落盘为 <id>.json,若这里再按
|
||||
// doc_ 前缀过滤,传自定义 ID 的文档重启后会静默消失。
|
||||
// 空 ID 仍会被下面的校验跳过。
|
||||
if !strings.HasSuffix(e.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(s.dir, e.Name()))
|
||||
@ -534,6 +550,11 @@ func (s *Store) loadAll() error {
|
||||
func (s *Store) flush() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.flushLocked()
|
||||
}
|
||||
|
||||
// flushLocked 是 flush 的核心,调用方必须已持有 s.mu。
|
||||
func (s *Store) flushLocked() {
|
||||
if !s.dirty {
|
||||
return
|
||||
}
|
||||
|
||||
@ -467,3 +467,188 @@ func TestContextToDocContentPreservesRawToolOutput(t *testing.T) {
|
||||
t.Error("summary should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
// ———— 回归:向量迁移的落盘与维度一致性 ————
|
||||
//
|
||||
// 以下四条来自 v1.2.0-beta.2 的压测(报告 /var/tmp/stress/REPORT.md):
|
||||
// 迁移结果不落盘(每次启动白算一遍)、块指纹对但维度错时污染文档向量、
|
||||
// 以及 Insert 与 loadAll 的 ID 约定不对称。
|
||||
|
||||
// fakeSpace 是可控的统一向量空间;calls 记录被真正要求算向量的次数,
|
||||
// 用来直接证明「已对齐的文档不再重算」——比读日志断言可靠。
|
||||
type fakeSpace struct {
|
||||
fp string
|
||||
dim int
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeSpace) VectorizeDense(string) ([]float64, error) {
|
||||
f.calls++
|
||||
v := make([]float64, f.dim)
|
||||
for i := range v {
|
||||
v[i] = float64(i + 1)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
func (f *fakeSpace) EmbedImageDense([]byte, string) ([]float64, error) { return f.VectorizeDense("") }
|
||||
func (f *fakeSpace) Fingerprint() string { return f.fp }
|
||||
func (f *fakeSpace) Dim() int { return f.dim }
|
||||
func (f *fakeSpace) Loaded() bool { return true }
|
||||
func (f *fakeSpace) Close() {}
|
||||
|
||||
// 迁移结果必须落盘:迁移后换一个 Store 实例(模拟重启)读到的应是新空间向量,
|
||||
// 且再跑一次迁移不应重算任何文档。
|
||||
func TestBuildDenseIndexPersistsAcrossRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sp := &fakeSpace{fp: "space-NEW-8", dim: 8}
|
||||
|
||||
s1 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s1.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 模拟上个向量空间留下的状态:维度与指纹都对不上
|
||||
stale := make([]float64, 999)
|
||||
for i := range stale {
|
||||
stale[i] = 0.01
|
||||
}
|
||||
if err := s1.Insert(&Doc{ID: "doc_persist", Summary: "迁移", Content: "落盘",
|
||||
DenseVec: stale, DenseFP: "space-OLD-999"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s1.SetDenseSpace(sp)
|
||||
s1.BuildDenseIndex(sp)
|
||||
if got := s1.Get("doc_persist"); got == nil || len(got.DenseVec) != 8 || got.DenseFP != sp.fp {
|
||||
t.Fatalf("迁移未在内存生效: %+v", got)
|
||||
}
|
||||
// 不调用 Stop 就另开一个实例:体现「迁移当场落盘」,不依赖关停
|
||||
s2 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s2.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s2.Stop()
|
||||
loaded := s2.Get("doc_persist")
|
||||
if loaded == nil {
|
||||
t.Fatal("重启后文档不见了")
|
||||
}
|
||||
if len(loaded.DenseVec) != 8 || loaded.DenseFP != sp.fp {
|
||||
t.Fatalf("迁移结果未落盘:期望 dim=8 fp=%q,实际 dim=%d fp=%q"+
|
||||
"(后果:每次启动都重算同一批文档,磁盘状态永不收敛)",
|
||||
sp.fp, len(loaded.DenseVec), loaded.DenseFP)
|
||||
}
|
||||
// 已对齐 → 一次向量计算都不该发生
|
||||
sp2 := &fakeSpace{fp: sp.fp, dim: 8}
|
||||
s2.SetDenseSpace(sp2)
|
||||
s2.BuildDenseIndex(sp2)
|
||||
if sp2.calls != 0 {
|
||||
t.Fatalf("已对齐的文档被重算了 %d 次(期望 0)", sp2.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 块向量维度与当前空间不符时不得参与融合:否则 512 维文本 + 2048 维块
|
||||
// 会被 FuseVectors 按最大维度拼成 2048 维、并带上当前指纹,导致该文档在
|
||||
// 检索侧被长度守卫永久跳过且每次启动重算。
|
||||
func TestDenseForIgnoresBlockWithMismatchedDim(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sp := &fakeSpace{fp: "space-NEW-8", dim: 8}
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Stop()
|
||||
|
||||
bad := make([]float64, 2048)
|
||||
for i := range bad {
|
||||
bad[i] = 0.02
|
||||
}
|
||||
good := make([]float64, 8)
|
||||
for i := range good {
|
||||
good[i] = 0.5
|
||||
}
|
||||
doc := &Doc{ID: "doc_bad", Summary: "坏块", Content: "文本向量应当生效",
|
||||
Blocks: []memory.MemoryBlock{
|
||||
{ID: "blk_bad", Vector: bad, Fingerprint: sp.fp}, // 指纹对、维度错 → 必须忽略
|
||||
{ID: "blk_good", Vector: good, Fingerprint: sp.fp}, // 指纹与维度都对 → 参与融合
|
||||
}}
|
||||
if err := s.Insert(doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetDenseSpace(sp)
|
||||
s.BuildDenseIndex(sp)
|
||||
|
||||
got := s.Get("doc_bad")
|
||||
if got == nil {
|
||||
t.Fatal("文档未加载")
|
||||
}
|
||||
if len(got.DenseVec) != 8 {
|
||||
t.Fatalf("坏块污染了文档向量:期望 %d 维,实际 %d 维(指纹 %q)",
|
||||
8, len(got.DenseVec), got.DenseFP)
|
||||
}
|
||||
// 同维度的正常块仍须参与融合:不应因为这次修复而整体失效
|
||||
textOnly := &fakeSpace{fp: sp.fp, dim: 8}
|
||||
textVec, _ := textOnly.VectorizeDense(doc.Summary + " " + doc.Content)
|
||||
if equalFloats(got.DenseVec, textVec) {
|
||||
t.Fatal("同维度的媒体块没有参与融合(修复过度)")
|
||||
}
|
||||
}
|
||||
|
||||
func equalFloats(a, b []float64) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Insert 接受任意 ID,loadAll 就必须把它读回来,否则自定义 ID 的文档
|
||||
// 重启后静默消失。
|
||||
func TestLoadAllLoadsCustomID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s1 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s1.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s1.Insert(&Doc{ID: "my-notes", Summary: "自定义 ID", Content: "内容"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s1.Stop()
|
||||
|
||||
s2 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s2.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s2.Stop()
|
||||
if s2.Get("my-notes") == nil {
|
||||
t.Fatal("自定义 ID 的文档重启后消失(Insert 与 loadAll 的 ID 约定不对称)")
|
||||
}
|
||||
}
|
||||
|
||||
// Stop 必须把内存态变更写盘(关停链上没有它时 flush 形同虚设)。
|
||||
func TestStopFlushesDirtyDocs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s1 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s1.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s1.Insert(&Doc{ID: "doc_flush", Summary: "关停落盘", Content: "内容"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 直接改内存并置脏,模拟「只在内存里发生的变更」
|
||||
s1.mu.Lock()
|
||||
s1.docs["doc_flush"].Summary = "关停落盘(已改)"
|
||||
s1.dirty = true
|
||||
s1.mu.Unlock()
|
||||
s1.Stop()
|
||||
|
||||
s2 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s2.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s2.Stop()
|
||||
if got := s2.Get("doc_flush"); got == nil || got.Summary != "关停落盘(已改)" {
|
||||
t.Fatalf("Stop 未落盘: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,6 +65,18 @@ func (a *ProviderAdapter) embed(input embedding.Input) ([]float64, error) {
|
||||
func (a *ProviderAdapter) Fingerprint() string { return a.info.Fingerprint }
|
||||
func (a *ProviderAdapter) Dim() int { return a.info.Dimension }
|
||||
|
||||
// Modalities 报告该空间支持的输入模态(text/image/...)。
|
||||
//
|
||||
// 模态是**可选能力**:MultimodalEmbedder 契约里没有它,状态查询按接口断言取用,
|
||||
// 所以这里既不改公开接口,也不影响其它实现(核心也不硬编码任何模型名)。
|
||||
func (a *ProviderAdapter) Modalities() []string {
|
||||
out := make([]string, 0, len(a.info.Modalities))
|
||||
for _, m := range a.info.Modalities {
|
||||
out = append(out, string(m))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *ProviderAdapter) Loaded() bool {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
|
||||
@ -77,6 +77,12 @@ type Store struct {
|
||||
docs []DocVector
|
||||
dim int
|
||||
index *InvertedIndex
|
||||
|
||||
// minScore 是候选分数下限。**必须按向量空间标定**:
|
||||
// 词向量/多模态余弦通常在 0.3~0.9,而 TF-IDF 余弦只有 0.0~0.2 ——
|
||||
// 用同一个阈值会把词法路的大量有效候选静默砍掉
|
||||
// (实测:知识库自检索 MRR 0.307 → 0.193 就是这么掉的)。
|
||||
minScore float64
|
||||
}
|
||||
|
||||
type DocVector struct {
|
||||
@ -86,9 +92,27 @@ type DocVector struct {
|
||||
Meta map[string]string
|
||||
}
|
||||
|
||||
// DefaultMinScore 是默认候选中选阈值(沿用历史行为)。
|
||||
const DefaultMinScore = 0.05
|
||||
|
||||
// MinScore 返回当前候选中选阈值(供接线处自证用的是哪个阈值)。
|
||||
func (s *Store) MinScore() float64 {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.minScore
|
||||
}
|
||||
|
||||
// SetMinScore 调整候选中选阈值(按向量空间标定,见 minScore 字段注释)。
|
||||
func (s *Store) SetMinScore(v float64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.minScore = v
|
||||
}
|
||||
|
||||
func NewStore() *Store {
|
||||
return &Store{
|
||||
index: NewInvertedIndex(),
|
||||
index: NewInvertedIndex(),
|
||||
minScore: DefaultMinScore,
|
||||
}
|
||||
}
|
||||
|
||||
@ -160,7 +184,7 @@ func (s *Store) SearchScored(query Vector, topK int) []DocVectorHit {
|
||||
for _, d := range s.docs {
|
||||
if d.ID == id {
|
||||
score := CosineSimilarity(query, d.Vector)
|
||||
if score > 0.05 {
|
||||
if score > s.minScore {
|
||||
results = append(results, scored{d, score})
|
||||
}
|
||||
break
|
||||
|
||||
@ -210,3 +210,29 @@ func BenchmarkExtractNGrams(b *testing.B) {
|
||||
extractNGrams(text, 2)
|
||||
}
|
||||
}
|
||||
|
||||
// 候选中选阈值必须**按向量空间标定**:词向量/多模态余弦通常在 0.3~0.9,
|
||||
// 而 TF-IDF 余弦只有 0.0~0.2。用同一个阈值会把词法路的有效候选静默砍掉
|
||||
// (知识库自检索 MRR 0.307→0.193 就是这么掉的,且当时看不出任何报错)。
|
||||
func TestSearchScoredRespectsMinScore(t *testing.T) {
|
||||
// 构造一个低余弦候选:共享特征 "a",但两个向量几乎正交 → cosine ≈ 0.02
|
||||
st := NewStore()
|
||||
st.Insert("doc", "", Vector{"a": 1, "b": 1}, nil) // |doc| = √2
|
||||
query := Vector{"a": 0.02, "c": 100} // 与 doc 的点积 0.02
|
||||
|
||||
hits := st.SearchScored(query, 10)
|
||||
for _, h := range hits {
|
||||
if h.Score < DefaultMinScore {
|
||||
t.Fatalf("默认阈值 %.2f 不该返回 %.5f 的候选", DefaultMinScore, h.Score)
|
||||
}
|
||||
}
|
||||
if len(hits) != 0 {
|
||||
t.Fatalf("该查询在默认阈值下应被过滤,实际返回 %d 条", len(hits))
|
||||
}
|
||||
|
||||
st.SetMinScore(0)
|
||||
hits = st.SearchScored(query, 10)
|
||||
if len(hits) != 1 || hits[0].Doc.ID != "doc" {
|
||||
t.Fatalf("阈值设为 0 后应召回低余弦候选,实际 %+v", hits)
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ var (
|
||||
//
|
||||
// 1.0.0:外部插件从 C ABI 动态库迁到子进程 + 共享内存。
|
||||
// 这是首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件必须
|
||||
// 用新版 plugindev 重编),故跃到主版本号。
|
||||
// 用新版 hmapdev 重编),故跃到主版本号。
|
||||
// 1.1.0:记忆系统支持二进制多媒体节点——CAS 媒体存储 + L0/L2/L3 贯通。
|
||||
// 1.1.1:多模态贯通**插件边界**。内核实现公开 SDK 1.1.0 新增的媒体接口
|
||||
// (doc.insertWithMedia、io.injectMedia / injectMediaSync /
|
||||
@ -24,7 +24,7 @@ var (
|
||||
//
|
||||
// ❗main 上此值始终是**下一个未发布中版本**,不随 patch 发布变动
|
||||
//(见 docs/git-branching.md §2.1);已发布的版本号看对应的 release/vX.Y.x 与 tag。
|
||||
Version = "1.2.0"
|
||||
Version = "1.2.2"
|
||||
|
||||
// Commit 是构建时的 Git commit hash。
|
||||
Commit = "unknown"
|
||||
@ -43,6 +43,19 @@ var (
|
||||
// 1.2.0。用 SDK 1.0.0/1.1.0 编的存量插件照旧可用——新增方法由
|
||||
// **插件调用、内核实现**,不调就不受影响,无需重编。
|
||||
SDKCompatibleVersion = "1.2.0"
|
||||
|
||||
// SourceURL 是本内核构建所对应的源码地址。
|
||||
//
|
||||
// AGPL-3.0 §13(Remote Network Interaction)要求:当你把修改过的版本
|
||||
// 作为网络服务提供出去时,必须给使用者提供取得 Corresponding Source 的机会。
|
||||
// WebUI 的状态页会把这个值渲染成可见链接,所以:
|
||||
//
|
||||
// ❗**修改后对外部署的分支必须把它改指向自己的源码仓库**,否则链接指向的
|
||||
// 不是你实际运行的那份代码,§13 的提供义务并未履行。
|
||||
//
|
||||
// 构建时可用 -ldflags 覆盖,无需改源码:
|
||||
// -X gitcode.com/JianFeeeee/HomeAgent/internal/meta.SourceURL=<你的仓库>
|
||||
SourceURL = "https://gitcode.com/JianFeeeee/HomeAgent"
|
||||
)
|
||||
|
||||
// FullVersion 返回完整的版本字符串。
|
||||
|
||||
@ -17,7 +17,7 @@ const (
|
||||
//
|
||||
// 保留这张表只为**给出明确错误**:插件目录里躺着 plugin.so 而内核不再认它时,
|
||||
// 静默跳过会让「目录在但插件没加载」看起来像配置问题,而实际原因是需要用
|
||||
// 新版 plugindev 重编。
|
||||
// 新版 hmapdev 重编。
|
||||
var legacyCABIEntries = []string{"plugin.so", "plugin.dll", "plugin.dylib"}
|
||||
|
||||
// entryKind 描述插件入口归属的加载通道。
|
||||
|
||||
@ -115,7 +115,7 @@ func TestHasLegacyCABIEntry(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// 旧 .so 插件必须报「用新 plugindev 重编」而非静默跳过。
|
||||
// 旧 .so 插件必须报「用新 hmapdev 重编」而非静默跳过。
|
||||
func TestTryDynamic_LegacyCABIGivesActionableError(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
defer r.closeProcHost()
|
||||
@ -129,7 +129,7 @@ func TestTryDynamic_LegacyCABIGivesActionableError(t *testing.T) {
|
||||
}
|
||||
// 错误消息须指向解决办法,且明确业务代码无需改
|
||||
msg := err.Error()
|
||||
for _, want := range []string{"plugindev", "plugin.bin", "业务代码"} {
|
||||
for _, want := range []string{"hmapdev", "plugin.bin", "业务代码"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("错误消息应含 %q,实际: %v", want, err)
|
||||
}
|
||||
|
||||
@ -10,11 +10,11 @@ import (
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// 端到端:用**真实 plugindev 模板**编译的插件,经内核 proc 通道加载运行。
|
||||
// 端到端:用**真实 hmapdev 模板**编译的插件,经内核 proc 通道加载运行。
|
||||
//
|
||||
// 与 plugin_test.go 中 testdata/*.go 假插件的区别:
|
||||
// 那些是手写的最简 RPC 实现,只验证内核侧逻辑;
|
||||
// 这里用的是 tools/plugindev/templates/proc_main.go.tmpl —— 外部插件作者
|
||||
// 这里用的是 tools/hmapdev/templates/proc_main.go.tmpl —— 外部插件作者
|
||||
// 真正会拿到的那份运行时。它验证的是「模板 ↔ 内核」两侧协议/布局真的对齐,
|
||||
// 而不只是内核自己跟自己对齐。
|
||||
//
|
||||
@ -101,9 +101,9 @@ func (p *e2ePlugin) Start(s *sdk.PluginSDK) error {
|
||||
func (p *e2ePlugin) Stop() error { return nil }
|
||||
`
|
||||
|
||||
// procRuntimeTemplates 列出 plugindev 会生成到插件目录的运行时文件。
|
||||
// procRuntimeTemplates 列出 hmapdev 会生成到插件目录的运行时文件。
|
||||
//
|
||||
// 必须与 SDK 仓 tools/plugindev/proc_runtime.go 的 procRuntimeFiles 一致:
|
||||
// 必须与 SDK 仓 tools/hmapdev/proc_runtime.go 的 procRuntimeFiles 一致:
|
||||
// 共享段与事件通知的传递机制按平台不同(Unix 继承 fd,Windows 命名
|
||||
// 内核对象),故拆成带 build tag 的文件;只写主模板会编译失败。
|
||||
var procRuntimeTemplates = []struct {
|
||||
@ -115,15 +115,21 @@ var procRuntimeTemplates = []struct {
|
||||
{"proc_shm_windows.go.tmpl", "z_proc_shm_windows.go"},
|
||||
}
|
||||
|
||||
// buildPluginWithRealTemplate 用 plugindev 的真实模板编译一个插件二进制。
|
||||
// buildPluginWithRealTemplate 用 hmapdev 的真实模板编译一个插件二进制。
|
||||
func buildPluginWithRealTemplate(t *testing.T, businessCode string) string {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("go"); err != nil {
|
||||
t.Skip("环境无 go 工具链,跳过端到端测试")
|
||||
}
|
||||
|
||||
// 工具链在 SDK 1.2.0 起改名 hmapdev(原 plugindev)。两个目录都接受:
|
||||
// 旧检出(软链或旧版 SDK 仓)仍能跑本测试,新检出走新路径。
|
||||
tmplDir := filepath.Join("..", "..", "..",
|
||||
"third_party", "homeagent-sdk", "tools", "plugindev", "templates")
|
||||
"third_party", "homeagent-sdk", "tools", "hmapdev", "templates")
|
||||
if _, err := os.Stat(tmplDir); err != nil {
|
||||
tmplDir = filepath.Join("..", "..", "..",
|
||||
"third_party", "homeagent-sdk", "tools", "plugindev", "templates")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
mustWriteFile(t, filepath.Join(dir, "plugin.go"), businessCode)
|
||||
@ -131,7 +137,7 @@ func buildPluginWithRealTemplate(t *testing.T, businessCode string) string {
|
||||
for _, rt := range procRuntimeTemplates {
|
||||
data, err := os.ReadFile(filepath.Join(tmplDir, rt.tmpl))
|
||||
if err != nil {
|
||||
t.Skipf("plugindev 模板 %s 不可读(SDK 仓可能未就位): %v", rt.tmpl, err)
|
||||
t.Skipf("hmapdev 模板 %s 不可读(SDK 仓可能未就位): %v", rt.tmpl, err)
|
||||
}
|
||||
mustWriteFile(t, filepath.Join(dir, rt.out), string(data))
|
||||
}
|
||||
|
||||
@ -260,7 +260,7 @@ func (p *Process) handshake(timeout time.Duration) error {
|
||||
return fmt.Errorf("proc: %s 握手应答解析失败: %w", p.name, err)
|
||||
}
|
||||
if res.Protocol != ProtocolVersion {
|
||||
return fmt.Errorf("proc: %s 协议版本不匹配(插件 %d,内核 %d)——请用配套 plugindev 重编",
|
||||
return fmt.Errorf("proc: %s 协议版本不匹配(插件 %d,内核 %d)——请用配套 hmapdev 重编",
|
||||
p.name, res.Protocol, ProtocolVersion)
|
||||
}
|
||||
log.Printf("[proc] %s 已建链(pid=%d protocol=%d sdk=%s)",
|
||||
|
||||
@ -331,7 +331,7 @@ func TestProcess_ProtocolMismatchRejected(t *testing.T) {
|
||||
}
|
||||
// 运维可读性:光报“不匹配”不能定位到行动。生产上碰到它的现场是
|
||||
// “只更新了内核没重编插件”,所以错误里必须带出这条修复指令。
|
||||
if !strings.Contains(err.Error(), "plugindev") || !strings.Contains(err.Error(), "重编") {
|
||||
if !strings.Contains(err.Error(), "hmapdev") || !strings.Contains(err.Error(), "重编") {
|
||||
t.Errorf("错误应给出重编插件的修复指令,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -143,7 +143,7 @@ func AttachSegment(data []byte) (*Segment, error) {
|
||||
return nil, fmt.Errorf("proc: 共享段魔数不匹配(0x%x,期望 0x%x)", got, shmMagic)
|
||||
}
|
||||
if got := binary.LittleEndian.Uint32(data[offVersion:]); got != shmVersion {
|
||||
return nil, fmt.Errorf("proc: 共享段版本不匹配(%d,本内核 %d)——插件需用配套 plugindev 重编",
|
||||
return nil, fmt.Errorf("proc: 共享段版本不匹配(%d,本内核 %d)——插件需用配套 hmapdev 重编",
|
||||
got, shmVersion)
|
||||
}
|
||||
return &Segment{data: data}, nil
|
||||
|
||||
2
internal/plugin/proc/testdata/stageplugin.go
vendored
2
internal/plugin/proc/testdata/stageplugin.go
vendored
@ -4,7 +4,7 @@
|
||||
// stage 处理经共享内存读改写(模拟 sanitizer 的清洗行为)。
|
||||
//
|
||||
// 它手写 RPC 与共享段访问,不依赖公开 SDK——因为 SDK 侧的 proc 支持
|
||||
// 属于 Part 3(plugindev 工具链)的内容。这里只验证内核侧机制。
|
||||
// 属于 Part 3(hmapdev 工具链)的内容。这里只验证内核侧机制。
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@ -1175,11 +1175,11 @@ func (r *Registry) tryDynamic(plgDir, name string, config map[string]interface{}
|
||||
|
||||
// 旧 .so/.dll 插件给明确错误,不静默跳过。
|
||||
// 静默跳过会让「插件目录在但没加载」看起来像配置问题,
|
||||
// 而实际原因是需要用新 plugindev 重编。
|
||||
// 而实际原因是需要用新 hmapdev 重编。
|
||||
if hasLegacyCABIEntry(plgDir) {
|
||||
return nil, fmt.Errorf(
|
||||
"plugin %s: 检测到旧 C ABI 产物(plugin.so/.dll/.dylib)。"+
|
||||
"外部插件已改为子进程模式,请用新版 plugindev 重编产出 %s"+
|
||||
"外部插件已改为子进程模式,请用新版 hmapdev 重编产出 %s"+
|
||||
"(业务代码无需修改)", name, binEntry)
|
||||
}
|
||||
|
||||
|
||||
@ -240,7 +240,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.selfToolNames["healthcheck_kernel"] = true
|
||||
s.RegisterTool("healthcheck_kernel", sdk.ToolDef{
|
||||
Name: "healthcheck_kernel",
|
||||
Description: "查询 Agent 内核运行状态快照,包括插件/工具/记忆/知识库/LLM Provider/运行时等各子系统信息。Agent 可通过此工具自主监测内核健康。",
|
||||
Description: "查询 Agent 内核运行状态快照,包括**内核版本号与构建身份**(build.version / commit / build_time)、**统一多模态向量空间(ONNX 模型)是否启用**(onnx.enabled,未启用时给出原因)、插件/工具/记忆/知识库/LLM Provider/运行时等各子系统信息。Agent 可通过此工具自主监测内核健康。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@ -23,9 +24,50 @@ import (
|
||||
// 这里用 **example/ 里真实的 17 个插件产物**,验证「业务代码零改动 + 重编即可」
|
||||
// 这一迁移承诺在完整内核装配下成立。
|
||||
//
|
||||
// 前置:插件需已用新版 plugindev 重编(scripts/rebuild-plugins.sh)。
|
||||
// 前置:插件需已用新版 hmapdev 重编(scripts/rebuild-plugins.sh)。
|
||||
// 未重编时测试 skip 而非 fail——CI 上不强制要求先跑重编脚本。
|
||||
|
||||
// hostExecutableKind 按魔数判断产物是不是**本机**可执行文件,返回可读格式名。
|
||||
//
|
||||
// 为何需要:以前只按文件名找候选(plugin.bin_<os>_<arch> → plugin.bin),拿到
|
||||
// darwin/windows 产物就直接 exec,报的是 "exec format error"——看起来像插件坏了,
|
||||
// 实际上只是**开发环境里的产物平台不对**。本轮就踩了:跨平台示例构建把
|
||||
// build/plugin.bin 覆盖成 Mach-O arm64,internal/plugins 6 个测试全红,排查半小时。
|
||||
func hostExecutableKind(path string) (string, bool) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "无法读取", false
|
||||
}
|
||||
defer f.Close()
|
||||
head := make([]byte, 4)
|
||||
n, _ := f.Read(head)
|
||||
if n < 2 {
|
||||
return "文件过短", false
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
if n >= 4 && head[0] == 0x7f && head[1] == 'E' && head[2] == 'L' && head[3] == 'F' {
|
||||
return "ELF(本机)", true
|
||||
}
|
||||
if n >= 4 && head[0] == 0xcf && head[1] == 0xfa && head[2] == 0xed && head[3] == 0xfe {
|
||||
return "Mach-O 64 little-endian(macOS,不是本机)", false
|
||||
}
|
||||
if n >= 2 && head[0] == 'M' && head[1] == 'Z' {
|
||||
return "PE(Windows,不是本机)", false
|
||||
}
|
||||
return "未知格式(不是本机可执行文件)", false
|
||||
case "darwin":
|
||||
for _, m := range [][]byte{{0xcf, 0xfa, 0xed, 0xfe}, {0xca, 0xfe, 0xba, 0xbe}} {
|
||||
if n >= 4 && bytes.Equal(head, m) {
|
||||
return "Mach-O(本机)", true
|
||||
}
|
||||
}
|
||||
return "不是本机可执行文件", false
|
||||
default:
|
||||
return "未知平台(默认放行)", true
|
||||
}
|
||||
}
|
||||
|
||||
// realPluginDir 返回某个 example 插件的 linux 产物路径。
|
||||
func realPluginBinary(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
@ -39,9 +81,14 @@ func realPluginBinary(t *testing.T, name string) string {
|
||||
filepath.Join(root, "build", "plugin.bin"),
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if st, err := os.Stat(c); err == nil && !st.IsDir() {
|
||||
return c
|
||||
if st, err := os.Stat(c); err != nil || st.IsDir() {
|
||||
continue
|
||||
}
|
||||
if kind, ok := hostExecutableKind(c); !ok {
|
||||
t.Skipf("插件 %s 的产物 %s 不是本机可执行格式(%s);重建:cd %s && hmapdev build --target %s/%s --no-bundle",
|
||||
name, c, kind, root, runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
return c
|
||||
}
|
||||
t.Skipf("插件 %s 未重编(先跑 scripts/rebuild-plugins.sh)", name)
|
||||
return ""
|
||||
|
||||
@ -1124,6 +1124,33 @@
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 首启人格向导(复用 confirm-* 弹窗)*/
|
||||
.persona-ta {
|
||||
width: 100%;
|
||||
display: none;
|
||||
margin-bottom: 12px;
|
||||
font: 12px/1.5 var(--font-mono, monospace);
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
border: 1px solid var(--glass-border);
|
||||
background: var(--glass-bg);
|
||||
color: var(--text-primary);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.persona-warn {
|
||||
display: none;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.persona-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
@ -2521,8 +2548,87 @@
|
||||
if (card) card.style.transform = "";
|
||||
});
|
||||
|
||||
// ===== API =====
|
||||
async function api(p, o) {
|
||||
// ===== 首启人格向导 =====
|
||||
// 人格是配置项(core.agent.personal_prompt,默认模板不含任何版本号)。
|
||||
// 首次启动问一次「默认 / 自定义 / 稍后」,之后不再打扰;
|
||||
// 不回答 = 稍后 = 保留默认人格,绝不阻塞启动。
|
||||
async function maybeShowPersonaWizard() {
|
||||
var st;
|
||||
try {
|
||||
st = await api("/persona");
|
||||
} catch (e) {
|
||||
return; // 拿不到状态就不打扰用户
|
||||
}
|
||||
if (!st || st.initialized) return;
|
||||
var ov = document.createElement("div");
|
||||
ov.className = "confirm-overlay";
|
||||
ov.style.display = "flex";
|
||||
ov.innerHTML =
|
||||
'<div class="confirm-box">' +
|
||||
"<h3>" + escHtml(__("人格设定", "Persona")) + "</h3>" +
|
||||
"<p>" + escHtml(__(
|
||||
"首次启动:选一下助手的人格。选「使用默认」即可(之后可在设置里修改);自定义内容在下次重启后生效。",
|
||||
"First run: pick your assistant's persona. \"Use default\" is fine (change it later in Settings); custom content takes effect after the next restart."
|
||||
)) + "</p>" +
|
||||
'<div class="persona-warn"></div>' +
|
||||
'<textarea class="persona-ta" rows="6"></textarea>' +
|
||||
'<div class="persona-actions">' +
|
||||
'<button class="btn btn-ghost btn-sm" data-mode="later">' + escHtml(__("稍后再说", "Later")) + "</button>" +
|
||||
'<button class="btn btn-ghost btn-sm" data-mode="custom">' + escHtml(__("自定义…", "Custom…")) + "</button>" +
|
||||
'<button class="btn btn-sm" data-mode="default">' + escHtml(__("使用默认", "Use default")) + "</button>" +
|
||||
"</div></div>";
|
||||
document.body.appendChild(ov);
|
||||
var ta = ov.querySelector(".persona-ta");
|
||||
var warn = ov.querySelector(".persona-warn");
|
||||
var customOpen = false;
|
||||
if (st.file_override) {
|
||||
warn.style.display = "block";
|
||||
warn.textContent = __(
|
||||
"注意:检测到 personal/personal.md,它优先于这里的设置。",
|
||||
"Note: personal/personal.md exists and takes precedence over this choice."
|
||||
);
|
||||
}
|
||||
function close() {
|
||||
ov.remove();
|
||||
}
|
||||
async function submit(mode, content) {
|
||||
try {
|
||||
var r = await api("/persona", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ mode: mode, content: content || "" }),
|
||||
});
|
||||
if (r && r.restart_required) toast(__("已保存,重启后生效", "Saved; takes effect after restart"));
|
||||
else toast(__("已保存", "Saved"));
|
||||
} catch (e) {
|
||||
toast(__("保存失败:", "Save failed: ") + e, true);
|
||||
}
|
||||
close();
|
||||
}
|
||||
ov.querySelectorAll("button[data-mode]").forEach(function (b) {
|
||||
b.onclick = function () {
|
||||
var mode = b.getAttribute("data-mode");
|
||||
if (mode !== "custom") {
|
||||
submit(mode);
|
||||
return;
|
||||
}
|
||||
if (!customOpen) { // 第一次点:展开文本域并预填当前人格
|
||||
customOpen = true;
|
||||
ta.style.display = "block";
|
||||
ta.value = st.current_prompt || "";
|
||||
ta.focus();
|
||||
return;
|
||||
}
|
||||
if (!ta.value.trim()) {
|
||||
toast(__("内容不能为空", "Content cannot be empty"), true);
|
||||
return;
|
||||
}
|
||||
submit("custom", ta.value);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ===== API =====
|
||||
async function api(p, o) {
|
||||
var opts = {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...o?.headers },
|
||||
@ -2750,6 +2856,18 @@
|
||||
: "-",
|
||||
) +
|
||||
"</span></div>" +
|
||||
// AGPL-3.0 §13:向网络使用者提供取得对应源码的入口。
|
||||
// 地址来自内核 meta.SourceURL(构建时可 -ldflags 覆盖),
|
||||
// 修改后对外部署的分支必须把它指向自己的源码仓库。
|
||||
(k?.build?.source_url
|
||||
? '<div class="kv-row"><span class="key">' +
|
||||
__("源码", "Source") +
|
||||
'</span><span class="val"><a href="' +
|
||||
escHtml(k.build.source_url) +
|
||||
'" target="_blank" rel="noopener noreferrer">' +
|
||||
escHtml(k.build.source_url) +
|
||||
"</a></span></div>"
|
||||
: "") +
|
||||
'<div class="kv-row"><span class="key">' +
|
||||
__("构建", "Build") +
|
||||
'</span><span class="val">' +
|
||||
@ -6504,6 +6622,7 @@
|
||||
renderAll();
|
||||
connectSSE();
|
||||
startUptimeTicker();
|
||||
maybeShowPersonaWizard();
|
||||
})();
|
||||
setInterval(renderAll, 15000);
|
||||
// 消息同步轮询兜底:每30秒增量同步 chatHistory,补偿 SSE 断连窗口期
|
||||
|
||||
@ -23,6 +23,7 @@ import (
|
||||
"time"
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||
@ -865,6 +866,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/terminals", h.requireAPI(h.handleTerminals))
|
||||
mux.HandleFunc("/api/v1/cmd/history", h.requireAPI(h.handleCmdHistory))
|
||||
mux.HandleFunc("/api/v1/kernel", h.requireAPI(h.handleKernel))
|
||||
mux.HandleFunc("/api/v1/persona", h.requireAPI(h.handlePersona))
|
||||
mux.HandleFunc("/api/v1/plugins", h.requireAPI(h.handlePlugins))
|
||||
mux.HandleFunc("/api/v1/plugins/", h.requireAPI(h.handlePluginByID))
|
||||
// 设备网关(可配置反代到 remotedevice;默认禁用,未启用时返回 404)
|
||||
@ -974,6 +976,77 @@ func (h *Handler) handleKernel(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, h.status.GetKernelStatus())
|
||||
}
|
||||
|
||||
// 人格设定:配置项键,以及「首启向导已经问过」的一次性标记。
|
||||
//
|
||||
// 为什么需要向导:人格曾经只有 <dataDir>/personal/personal.md 一个来源且无人维护,
|
||||
// 里面写死的旧版本号反过来让实例自述旧版本(v1.2.0 压测发现)。
|
||||
// 现在人格是配置项(默认模板不含任何版本号),首启问一次,之后不再打扰。
|
||||
// handlePersona 是首启人格向导的后端(与内核 persona_set 工具共用 internal/config 的实现)。
|
||||
//
|
||||
// GET → {initialized, current_prompt, file_override}
|
||||
// POST → {"mode":"default"|"custom"|"later","content":"..."}
|
||||
// 写入 core.agent.personal_prompt 并打一次性标记,返回 restart_required
|
||||
//
|
||||
// 生效时机:人格在 homed 启动时载入(以【人格设定】块拼进系统提示词),
|
||||
// 所以**自定义内容需重启生效**;选「默认」或「稍后」(保持当前默认)无需重启。
|
||||
// 不回答就是「稍后」:保留默认并打标记,不阻塞任何流程。
|
||||
//
|
||||
// 跨通道:这里只是 WebUI 侧的入口;任何通道的消息到来时,内核都会检查同一枚标记,
|
||||
// 未确认则在提示词里要求模型主动询问(见 internal/agent/core 的首启门禁)。
|
||||
func (h *Handler) handlePersona(w http.ResponseWriter, r *http.Request) {
|
||||
if h.settings == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "settings not available"})
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"initialized": internalConfig.PersonaInitializedKV(h.settings),
|
||||
"current_prompt": internalConfig.CurrentPersonaKV(h.settings),
|
||||
"file_override": h.personaFileExists(),
|
||||
})
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
Mode string `json:"mode"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
restart, err := internalConfig.SetPersonaKV(h.settings, req.Mode, req.Content)
|
||||
if err != nil {
|
||||
// 非法 mode / 空内容 → 400;落库失败 → 500。两者都不打标记。
|
||||
code := http.StatusInternalServerError
|
||||
if strings.Contains(err.Error(), "unknown mode") || strings.Contains(err.Error(), "content required") {
|
||||
code = http.StatusBadRequest
|
||||
}
|
||||
writeJSON(w, code, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "ok", "mode": req.Mode, "restart_required": restart,
|
||||
})
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// personaFileExists 报告是否存在会覆盖配置项的人格文件(存在时它优先)。
|
||||
// 数据目录取自 core.daemon.data_dir(由播种写入)。
|
||||
func (h *Handler) personaFileExists() bool {
|
||||
v, err := h.settings.GetCore("core.daemon.data_dir")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
dir, _ := v.(string)
|
||||
if dir == "" {
|
||||
return false
|
||||
}
|
||||
_, err = os.Stat(filepath.Join(dir, "personal", "personal.md"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (h *Handler) handleAgents(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
|
||||
145
internal/plugins/webui/handler_persona_test.go
Normal file
145
internal/plugins/webui/handler_persona_test.go
Normal file
@ -0,0 +1,145 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
func newPersonaHandler(t *testing.T) (*Handler, *internalConfig.ConfigRegistry) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
cfgReg := internalConfig.NewConfigRegistry(filepath.Join(dir, "config.db"))
|
||||
cfgReg.SeedDefaults(dir)
|
||||
t.Cleanup(func() { cfgReg.Close() })
|
||||
h := NewHandler(testSDK(sdk.SDKConfig{Settings: sdk.NewSettings("webui", cfgReg)}))
|
||||
return h, cfgReg
|
||||
}
|
||||
|
||||
func doPersona(t *testing.T, h *Handler, method, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var rd *strings.Reader
|
||||
if body == "" {
|
||||
rd = strings.NewReader("")
|
||||
} else {
|
||||
rd = strings.NewReader(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, "/api/v1/persona", rd)
|
||||
w := httptest.NewRecorder()
|
||||
h.handlePersona(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// 首启向导的后端契约:GET 报告状态、POST 三选一、并且**只问一次**。
|
||||
func TestPersonaWizardFlow(t *testing.T) {
|
||||
h, cfgReg := newPersonaHandler(t)
|
||||
|
||||
// 1. 全新安装:未初始化,current_prompt 回落到内置默认模板
|
||||
w := doPersona(t, h, http.MethodGet, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET 状态码 %d", w.Code)
|
||||
}
|
||||
var got struct {
|
||||
Initialized bool `json:"initialized"`
|
||||
CurrentPrompt string `json:"current_prompt"`
|
||||
FileOverride bool `json:"file_override"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Initialized {
|
||||
t.Fatal("全新安装不应已初始化")
|
||||
}
|
||||
if got.CurrentPrompt != internalConfig.DefaultPersonaPrompt {
|
||||
t.Fatal("未设置时应回落到内置默认模板")
|
||||
}
|
||||
if got.FileOverride {
|
||||
t.Fatal("没有人格文件时不应报告 file_override")
|
||||
}
|
||||
|
||||
// 2. 「稍后再说」= 保留默认、打标记、不再问
|
||||
w = doPersona(t, h, http.MethodPost, `{"mode":"later"}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("later 状态码 %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if v := cfgReg.GetString(internalConfig.PersonaInitMarkerKey, ""); v == "" {
|
||||
t.Fatal("later 也必须打一次性标记(否则每次启动都问)")
|
||||
}
|
||||
if v := cfgReg.GetString(internalConfig.PersonaPromptKey, ""); v != internalConfig.DefaultPersonaPrompt {
|
||||
t.Fatalf("later 不应改动人格,实际 %q", v)
|
||||
}
|
||||
|
||||
// 3. 已初始化后 GET 应报 true
|
||||
w = doPersona(t, h, http.MethodGet, "")
|
||||
got.Initialized = false
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &got)
|
||||
if !got.Initialized {
|
||||
t.Fatal("打过标记后应报告已初始化")
|
||||
}
|
||||
|
||||
// 4. 自定义:写入内容 + 需要重启(人格在启动时载入)
|
||||
h2, cfgReg2 := newPersonaHandler(t)
|
||||
w = doPersona(t, h2, http.MethodPost, `{"mode":"custom","content":"你是测试人格"}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("custom 状态码 %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var pr struct {
|
||||
RestartRequired bool `json:"restart_required"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &pr)
|
||||
if !pr.RestartRequired {
|
||||
t.Fatal("自定义人格应提示需要重启才生效")
|
||||
}
|
||||
if v := cfgReg2.GetString(internalConfig.PersonaPromptKey, ""); v != "你是测试人格" {
|
||||
t.Fatalf("自定义内容未写库: %q", v)
|
||||
}
|
||||
|
||||
// 5. 空内容的 custom 必须被拒(否则等于静默清空人格)
|
||||
h3, _ := newPersonaHandler(t)
|
||||
if w = doPersona(t, h3, http.MethodPost, `{"mode":"custom","content":" "}`); w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("空内容应 400,实际 %d", w.Code)
|
||||
}
|
||||
// 6. 未知 mode 必须被拒
|
||||
if w = doPersona(t, h3, http.MethodPost, `{"mode":"nope"}`); w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("未知 mode 应 400,实际 %d", w.Code)
|
||||
}
|
||||
// 7. 被拒的请求不得打标记(否则向导会被跳过)
|
||||
if v := cfgReg2.GetString(internalConfig.PersonaInitMarkerKey, ""); v == "" {
|
||||
t.Fatal("前置条件:第 4 步已打标记")
|
||||
}
|
||||
h4, cfgReg4 := newPersonaHandler(t)
|
||||
_ = doPersona(t, h4, http.MethodPost, `{"mode":"nope"}`)
|
||||
if v := cfgReg4.GetString(internalConfig.PersonaInitMarkerKey, ""); v != "" {
|
||||
t.Fatal("被拒的请求不应打标记")
|
||||
}
|
||||
}
|
||||
|
||||
// 存在人格文件时 GET 要报告 file_override(它会覆盖配置项,向导应提示用户)。
|
||||
func TestPersonaWizardReportsFileOverride(t *testing.T) {
|
||||
h, cfgReg := newPersonaHandler(t)
|
||||
dir := cfgReg.GetString("core.daemon.data_dir", "")
|
||||
if dir == "" {
|
||||
t.Fatal("播种应写入 core.daemon.data_dir")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(dir, "personal"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "personal", "personal.md"), []byte("旧人格"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := doPersona(t, h, http.MethodGet, "")
|
||||
var got struct {
|
||||
FileOverride bool `json:"file_override"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &got)
|
||||
if !got.FileOverride {
|
||||
t.Fatal("存在 personal.md 时必须报告 file_override")
|
||||
}
|
||||
}
|
||||
@ -38,9 +38,29 @@ type KernelStatus struct {
|
||||
|
||||
Runtime RuntimeStatus `json:"runtime"`
|
||||
|
||||
// ONNX 报告统一多模态向量空间(ONNX 模型)是否**真的在用**。
|
||||
//
|
||||
// 为何单列:内核的向量能力是三层降级(统一多模态空间 → 词嵌入 → TF-IDF),
|
||||
// 只报「向量可用/不可用」分不清「ONNX 模型已加载」与「退回了纯文本路径」。
|
||||
// 模型缺失 / 运行时缺失 / provider 打开失败时这里是 enabled=false + reason。
|
||||
ONNX ONNXStatus `json:"onnx"`
|
||||
|
||||
Tracker TrackerStatus `json:"tracker"`
|
||||
}
|
||||
|
||||
// ONNXStatus 是统一多模态向量空间(ONNX 模型)的启用状态与身份。
|
||||
type ONNXStatus struct {
|
||||
// Enabled 是 provider 真正打开且元数据合法(不是「配置里写了 provider」)。
|
||||
Enabled bool `json:"enabled"`
|
||||
// Provider 是配置指定的 provider 名(如 chineseclip / qwen3vl / http)。
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Dim int `json:"dim,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
Modalities []string `json:"modalities,omitempty"`
|
||||
// Reason 是未启用时的原因(未配置 / 打开失败的具体错误 / 其它)。
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type PluginInfo struct {
|
||||
Name string `json:"name"`
|
||||
Loaded bool `json:"loaded"`
|
||||
@ -111,6 +131,10 @@ type BuildStatus struct {
|
||||
SDKCompatible string `json:"sdk_compatible"`
|
||||
// KernelName 是内核名(HomeAgent)。
|
||||
KernelName string `json:"kernel_name"`
|
||||
// SourceURL 是本次构建对应的源码地址。AGPL-3.0 §13 要求:向使用者提供网络
|
||||
// 服务时,要给他们拿到 Corresponding Source 的机会——WebUI 状态页会把它渲染成
|
||||
// 可见链接,所以**修改后对外部署的分支必须把这个值改指向自己的源码仓库**。
|
||||
SourceURL string `json:"source_url,omitempty"`
|
||||
}
|
||||
|
||||
type TrackerStatus struct {
|
||||
|
||||
661
third_party/homeagent-sdk/LICENSE
vendored
Normal file
661
third_party/homeagent-sdk/LICENSE
vendored
Normal file
@ -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/>.
|
||||
11
third_party/homeagent-sdk/meta/meta.go
vendored
11
third_party/homeagent-sdk/meta/meta.go
vendored
@ -41,8 +41,15 @@ var (
|
||||
// ❗main 分支上此值是**下一个未发布中版本**;已发布的值看对应的
|
||||
// release/vX.Y.x 分支与 tag(见 核心仓 docs/git-branching.md §2.1 与 §七.1)。
|
||||
//
|
||||
// 现为 1.2.0:1.1.x 线正在发布中(release/v1.1.x 上定版 1.1.0),
|
||||
// main 在积攒 1.2 的东西。1.2.0 本身还没有任何 tag。
|
||||
// 现为 1.2.0:核心的 1.2.x 线正在发布中(release/v1.2.x 承载 1.2.0),
|
||||
// 但 **SDK 不跟 beta 发版**(§七.2)——SDK 1.2.0 的定版与 tag 随核心的
|
||||
// **正式** tag 一起做(§七.3)。在那之前 1.2.0 仍是 SDK 尚未发布的中版本,
|
||||
// 所以 main 就停在 1.2.0。
|
||||
//
|
||||
// 注意:这里与核心 main **故意不对称**。核心一旦切出 release/v1.2.x,
|
||||
// 1.2.0 就归发布线所有,main 立刻推进到 1.3.0;而 SDK 因为要等正式 tag,
|
||||
// 它的 main 在 v1.2.0 打出来之前不得越过 1.2.0。
|
||||
// (曾误按 §七.4 把这里推到 1.3.0,等于宣称 1.2.0 已发布。)
|
||||
Version = "1.2.0"
|
||||
|
||||
// Commit 是构建时的 Git commit hash。
|
||||
|
||||
Reference in New Issue
Block a user