mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-09-21 09:28:04 +00:00
Compare commits
37 Commits
v0.9.2
...
release/v1
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d1e899b18 | |||
| f6d9fee2da | |||
| 1df184d47e | |||
| ff4484e193 | |||
| d011d0676a | |||
| 140cd34b56 | |||
| b237787c90 | |||
| 69ff3089a4 | |||
| e839eb8220 | |||
| d893bfa76f | |||
| 93ab794a82 | |||
| 12cabcb290 | |||
| 44bd915fbf | |||
| ba49dfda44 | |||
| b2eafdf885 | |||
| 8c397ecf65 | |||
| 632f6743d3 | |||
| 9d930db4ea | |||
| 0a164fe4b9 | |||
| fd5a291df1 | |||
| 5175e7d6e0 | |||
| 71e3325439 | |||
| 18fec9b003 | |||
| fc236120e3 | |||
| a66739e59b | |||
| da01af1ad7 | |||
| 741e284cd4 | |||
| ce5bff9275 | |||
| e256023399 | |||
| 092d8f4ab0 | |||
| 5ed8d65479 | |||
| 9f844123fe | |||
| ef0e58ee23 | |||
| 09b64dcb53 | |||
| 56485194df | |||
| 61f307be1a | |||
| 59c6e1844c |
5
.gitignore
vendored
5
.gitignore
vendored
@ -10,6 +10,11 @@ plugin.json
|
|||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# plugindev 预编译二进制:只作为 release 附件分发,不进仓库历史。
|
||||||
|
# 此前 5 个平台各 26-28MB 被 git 跟踪(约 137MB),每次重编都在历史里
|
||||||
|
# 再叠一份,而它们本质是可从源码复现的产物。
|
||||||
|
bin/
|
||||||
|
|
||||||
# Test artifacts
|
# Test artifacts
|
||||||
testdist/
|
testdist/
|
||||||
|
|
||||||
|
|||||||
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/>.
|
||||||
294
README.md
294
README.md
@ -2,6 +2,70 @@
|
|||||||
|
|
||||||
HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插件。
|
HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插件。
|
||||||
|
|
||||||
|
## 版本与兼容性
|
||||||
|
|
||||||
|
当前:**SDK 1.2.0**(需内核 **1.2.0+**)。
|
||||||
|
|
||||||
|
**版本号跟随内核的中版本,patch 位恒为 `.0`**:
|
||||||
|
|
||||||
|
| 内核版本 | 对应 SDK |
|
||||||
|
|---|---|
|
||||||
|
| 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 |
|
||||||
|
| 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** |
|
||||||
|
| 1.2.0 起 | 1.2.0 |
|
||||||
|
|
||||||
|
内核的 patch 位专用于 bugfix 与漏洞修复,不碰公开接口,所以 SDK 版本号不跟着动——
|
||||||
|
否则你要么被迫跟版、要么怀疑自己版本过时,而接口其实一个字都没变。
|
||||||
|
|
||||||
|
**1.0.x 插件升到 1.1.x:不需要改代码,也不需要重编。** 1.1.0 的新增全部是
|
||||||
|
「插件调用、内核实现」方向,不调就不受影响(已用 SDK 0.9.2 编的旧 `plugin.bin`
|
||||||
|
实测验证:在新内核上直接建链通过,因为握手校验的是 `ProtocolVersion`、不是 SDK 版本)。
|
||||||
|
想用新字段时重编即可。
|
||||||
|
|
||||||
|
**1.1.x 插件升到 1.2.x:接口纯追加,但必须重编。** 公开接口没有签名变更(新增
|
||||||
|
`InjectOptions` 与六个 `*Opts` 变体、`ChannelDef.ContextPolicy`),不调新能力就不受影响;
|
||||||
|
但内核的**插件运行协议升到了 2**(统一共享内存区的 fd3 布局改变,**不支持滚动升级**),
|
||||||
|
所以 `plugin.bin` 必须用配套的 `hmapdev` 重编后与内核**同批**安装——否则握手时协议版本
|
||||||
|
不匹配会被拒绝(错误信息会明确提示用配套 hmapdev 重编,不会静默降级)。
|
||||||
|
|
||||||
|
## 注入行为与上下文裁剪(1.2.0)
|
||||||
|
|
||||||
|
「记不记入记忆」与「要不要据此裁剪上下文」这两件事,原先只有 `ToolDef` 能声明;
|
||||||
|
1.2.0 起**注入侧也能声明**,并且二者共用同一套语义与取值。
|
||||||
|
|
||||||
|
```go
|
||||||
|
type InjectOptions struct {
|
||||||
|
NoMemory bool // true = 不参与记忆计算(向量化/关键词提取/蒸馏),原文仍留在上下文
|
||||||
|
ContextPolicy string // ""/none = 不裁剪(默认);prune = 据此裁剪上下文
|
||||||
|
CleanerName string // 计算层过滤函数名:先经 Cleaner 得到实际有效内容,再计算/裁剪
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
ContextPolicyNone = "none"
|
||||||
|
ContextPolicyPrune = "prune"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 六个变体,与旧的三参数方法一一对应,只多一个 opts
|
||||||
|
InjectTextOpts(source, channel, text string, opts InjectOptions)
|
||||||
|
InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
|
||||||
|
InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
|
||||||
|
InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||||
|
InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
|
||||||
|
InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
要点:
|
||||||
|
|
||||||
|
- **零值 `InjectOptions{}` 与旧的三参数方法逐键等价**(记入记忆 + 不裁剪)。旧方法保留为
|
||||||
|
零值糖(`InjectText` / `InjectInterruptText` / `InjectTextNoMemory` …),存量插件不改一行、
|
||||||
|
不需重编即可继续调用。
|
||||||
|
- **裁剪(`prune`)必须显式声明**:它会归档丢弃低相关事件,是有副作用的行为,故默认关闭。
|
||||||
|
内核只放行 `""` / `none` / `prune`(`ValidContextPolicy`),未声明的取值会被拒。
|
||||||
|
- 裁剪前先经该插件注册的 **`Cleaner`**(由 `CleanerName` 指定)拿到实际有效内容,
|
||||||
|
避开「按原文裁剪、按清洗后计算」这种不一致。
|
||||||
|
- `ChannelDef` 也有同名 `context_policy`(并且 1.2.0 给它补上了 JSON tag——通道定义要跨进程
|
||||||
|
传给内核,而 `Cleaner` 是函数必须忽略;无 tag 时新增字段会被静默丢掉)。
|
||||||
|
|
||||||
## SDK API 接口
|
## SDK API 接口
|
||||||
|
|
||||||
### Plugin 接口
|
### Plugin 接口
|
||||||
@ -36,6 +100,7 @@ type Plugin interface {
|
|||||||
| 设置 | `Settings()` | 访问设置 API |
|
| 设置 | `Settings()` | 访问设置 API |
|
||||||
| 事件 | `Events()` | 访问事件订阅器(外部插件仅订阅) |
|
| 事件 | `Events()` | 访问事件订阅器(外部插件仅订阅) |
|
||||||
| 注入 | `InjectText(source, channel, text)` / `InjectInterruptText(source, channel, text)` / `InjectTextNoMemory(source, channel, text)` | 向管道注入文本 |
|
| 注入 | `InjectText(source, channel, text)` / `InjectInterruptText(source, channel, text)` / `InjectTextNoMemory(source, channel, text)` | 向管道注入文本 |
|
||||||
|
| 多模态注入 | `InjectInputMedia(source, channel, text, blocks)` / `InjectInputMediaSync(...)` / `InjectInterruptMedia(...)` | 注入带图片/音频的输入(1.1.0 新增) |
|
||||||
| 自动重启 | `SetAutoRestart(enabled)` / `AutoRestart()` | 控制崩溃自动重启 |
|
| 自动重启 | `SetAutoRestart(enabled)` / `AutoRestart()` | 控制崩溃自动重启 |
|
||||||
|
|
||||||
### 阶段钩子
|
### 阶段钩子
|
||||||
@ -106,6 +171,30 @@ type 枚举值:
|
|||||||
| `InjectInterruptText(source, channel, text)` | 注入中断文本,打断当前处理,路由到指定通道 |
|
| `InjectInterruptText(source, channel, text)` | 注入中断文本,打断当前处理,路由到指定通道 |
|
||||||
| `InjectTextNoMemory(source, channel, text)` | 注入文本,不记入内存,路由到指定通道 |
|
| `InjectTextNoMemory(source, channel, text)` | 注入文本,不记入内存,路由到指定通道 |
|
||||||
|
|
||||||
|
### 多模态注入(1.1.0 新增)
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `InjectInputMedia(source, channel, text, blocks)` | 注入带媒体的输入,异步 |
|
||||||
|
| `InjectInputMediaSync(source, channel, text, blocks)` | 注入带媒体的输入并同步等待回复文本 |
|
||||||
|
| `InjectInterruptMedia(source, channel, text, blocks)` | 注入带媒体的中断,可抢占当前处理 |
|
||||||
|
|
||||||
|
`blocks` 是 `[]sdk.ContentBlock`,与 `SetToolBlocks` 用同一类型:
|
||||||
|
|
||||||
|
```go
|
||||||
|
s.InjectInputMedia("myplugin", "webui", "帮我看看这张图", []sdk.ContentBlock{{
|
||||||
|
Type: "image_url",
|
||||||
|
ImageURL: &sdk.ImageURL{URL: "data:image/png;base64," + b64, Detail: "auto"},
|
||||||
|
}})
|
||||||
|
```
|
||||||
|
|
||||||
|
与 `SetToolBlocks` 的区别:`SetToolBlocks` 只能在工具处理函数内部调用,媒体要等到
|
||||||
|
下一条 tool message 才到模型手上;这三个方法是插件**主动发起一轮带媒体的对话**,
|
||||||
|
媒体在本轮就随消息发给模型,并自动落进媒体存储、挂上媒体记忆引用。
|
||||||
|
|
||||||
|
媒体块里的 `data:` URL 会被内核落盘去重;`http(s)` URL 只透传给模型,不入库
|
||||||
|
(入库需要内核发起网络请求,涉及超时、鉴权与 SSRF)。
|
||||||
|
|
||||||
`source` 标识来源,`channel` 指定目标输出通道。
|
`source` 标识来源,`channel` 指定目标输出通道。
|
||||||
|
|
||||||
### Triple 扩展字段
|
### Triple 扩展字段
|
||||||
@ -115,6 +204,61 @@ Triple 数据结构新增字段:
|
|||||||
- `Confidence` — 置信度(0.0~1.0)
|
- `Confidence` — 置信度(0.0~1.0)
|
||||||
- `SubjectType` — 主体类型
|
- `SubjectType` — 主体类型
|
||||||
- `ObjectType` — 客体类型
|
- `ObjectType` — 客体类型
|
||||||
|
- `SentenceText` — 原始句子文本(1.1.0 新增),写入 `sentences` 表;媒体引用挂在句子上
|
||||||
|
- `MediaDigests` — 关联的媒体 digest 列表(1.1.0 新增)
|
||||||
|
|
||||||
|
### 记忆里的媒体(1.1.0 新增)
|
||||||
|
|
||||||
|
媒体在纯文本记忆里以**标记**形式存在,格式 `[<mime> <短digest>] <描述>`:
|
||||||
|
|
||||||
|
```
|
||||||
|
[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图
|
||||||
|
```
|
||||||
|
|
||||||
|
描述文本是持久的语义记忆(检索靠它),digest 是回到字节的钥匙(反查靠它)。
|
||||||
|
标记由内核生成,插件不必自己拼——**填 digest 就够**。
|
||||||
|
|
||||||
|
#### 图记忆
|
||||||
|
|
||||||
|
```go
|
||||||
|
s.Memory().Commit([]sdk.Triple{{
|
||||||
|
Subject: "配色图", Relation: "包含", Object: "三色带",
|
||||||
|
MediaDigests: []string{"a1b2c3d4e5f6"}, // 短 digest 即可,内核补全
|
||||||
|
}})
|
||||||
|
```
|
||||||
|
|
||||||
|
没给 `SentenceText` 时内核会用标记本身充当句子——媒体必须有句子落点,
|
||||||
|
否则引用无从挂起。
|
||||||
|
|
||||||
|
#### 知识库
|
||||||
|
|
||||||
|
```go
|
||||||
|
s.DocMemory().InsertWithMedia(&sdk.Doc{
|
||||||
|
Title: "带图笔记",
|
||||||
|
Content: "正文",
|
||||||
|
}, []sdk.MediaAttachment{
|
||||||
|
{MIME: "image/png", Data: pngBytes, Name: "chart.png"}, // 新内容,落盘去重
|
||||||
|
{Digest: "a1b2c3d4e5f6"}, // 引用已有内容
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`Insert` 保持原签名不变,正文里已有的标记同样会被挂成文档级引用。
|
||||||
|
`Query` 返回的 `Doc` 带 `MediaDigests` 与 `Attachments`(mime + 描述,
|
||||||
|
**不含字节**——一次检索可能命中几十份媒体)。删除文档时引用自动释放。
|
||||||
|
|
||||||
|
#### 文本记忆
|
||||||
|
|
||||||
|
```go
|
||||||
|
s.TextMemory().Append(sdk.TextEvent{
|
||||||
|
Role: "user", Content: "看这张图",
|
||||||
|
Attachments: []sdk.MediaAttachment{{MIME: "image/png", Data: pngBytes}},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`RecentEvents` 读回时正文里的标记会被反解成 `Attachments`。
|
||||||
|
|
||||||
|
媒体存储可在内核侧关闭(`core.memory.media.enabled=false`),此时以上接口
|
||||||
|
全部退化为纯文本行为:不报错、不 panic,与本特性上线前一致。
|
||||||
|
|
||||||
### ToolDef 字段说明
|
### ToolDef 字段说明
|
||||||
|
|
||||||
@ -140,22 +284,35 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
|
|||||||
|
|
||||||
插件开发者只需实现 `Plugin` 接口并导出 `NewPluginFactory()` 入口函数。
|
插件开发者只需实现 `Plugin` 接口并导出 `NewPluginFactory()` 入口函数。
|
||||||
|
|
||||||
## plugindev 工具链
|
## hmapdev 工具链
|
||||||
|
|
||||||
`plugindev` 提供插件开发全流程支持。仓库 `bin/` 提供各平台预制二进制(linux/darwin/windows × amd64/arm64),下载后直接加入 PATH 即可:
|
`hmapdev` 提供插件开发全流程支持,最终产出 `.hmap` 插件包(工具名即来自该包格式)。
|
||||||
|
预编译二进制作为 **release 附件**分发(linux/darwin/windows × amd64/arm64),从
|
||||||
|
[Releases](https://gitcode.com/JianFeeeee/homeagent-sdk/releases) 下载后加入 PATH 即可:
|
||||||
|
|
||||||
|
> 改名说明:工具链原名 `plugindev`,自 1.2.0 起更名 `hmapdev`。
|
||||||
|
> SDK 存储目录同时由 `~/.homeagent/plugindev/sdk` 迁到 `~/.homeagent/hmapdev/sdk`
|
||||||
|
> (旧目录会被自动沿用,不会丢已装版本)。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -o plugindev https://gitcode.com/JianFeeeee/homeagent-sdk/-/raw/main/bin/plugindev_linux_amd64
|
# 从 release 附件下载(以最新 SDK 发布 / linux amd64 为例)
|
||||||
chmod +x plugindev
|
curl -Lo hmapdev https://gitcode.com/JianFeeeee/homeagent-sdk/releases/download/<版本>/hmapdev_linux_amd64
|
||||||
|
chmod +x hmapdev
|
||||||
|
|
||||||
|
# 或从源码自己编
|
||||||
|
cd tools/hmapdev && go build -o hmapdev .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 二进制不再随仓库分发(旧的 `bin/` 目录已停用):5 个平台各 26-28MB,
|
||||||
|
> 每次重编都在 git 历史里再叠一份,而它们本质是可从源码复现的产物。
|
||||||
|
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `plugindev init <name> [--lua]` | 初始化插件项目(生成 plg.json、plugin.go 或 main.lua、go.mod、README.md) |
|
| `hmapdev init <name> [--lua]` | 初始化插件项目(生成 plg.json、plugin.go 或 main.lua、go.mod、README.md) |
|
||||||
| `plugindev build [flags]` | 编译并打包为 `.hmap` 包(支持跨平台编译和 bundle 模式) |
|
| `hmapdev build [flags]` | 编译并打包为 `.hmap` 包(支持跨平台编译和 bundle 模式) |
|
||||||
| `plugindev clean` | 清理 `build/`、`dist/` 目录及生成文件(plugin.json、z_bridge_gen.go) |
|
| `hmapdev clean` | 清理 `build/`、`dist/` 目录及生成文件(plugin.json、z_bridge_gen.go) |
|
||||||
| `plugindev debug [dir]` | 通过 Yaegi Go 解释器加载插件源码,启动交互式 REPL 调试 |
|
| `hmapdev debug [dir]` | 通过 Yaegi Go 解释器加载插件源码,启动交互式 REPL 调试 |
|
||||||
| `plugindev sdk <command>` | SDK 版本管理(子命令:list/install/use/path/current/latest) |
|
| `hmapdev sdk <command>` | SDK 版本管理(子命令:list/install/use/path/current/latest) |
|
||||||
|
|
||||||
支持 **Go** 和 **Lua** 两种插件语言。
|
支持 **Go** 和 **Lua** 两种插件语言。
|
||||||
|
|
||||||
@ -180,7 +337,7 @@ chmod +x plugindev
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "天气查询插件",
|
"description": "天气查询插件",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.bin",
|
||||||
"tags": ["weather", "forecast"],
|
"tags": ["weather", "forecast"],
|
||||||
"targets": "linux/amd64,windows/amd64",
|
"targets": "linux/amd64,windows/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
@ -202,7 +359,7 @@ chmod +x plugindev
|
|||||||
| `version` | string | 版本号 |
|
| `version` | string | 版本号 |
|
||||||
| `description` | string | 插件描述 |
|
| `description` | string | 插件描述 |
|
||||||
| `author` | string | 作者 |
|
| `author` | string | 作者 |
|
||||||
| `entry` | string | 入口文件(`plugin.so` / `plugin.dll` / `main.lua`) |
|
| `entry` | string | 入口文件(`plugin.bin` / `main.lua`)。v1.0.0 起 Go 插件统一为 `plugin.bin`,不再区分平台后缀 |
|
||||||
| `tags` | string[] | 标签 |
|
| `tags` | string[] | 标签 |
|
||||||
| `targets` | string | 构建目标,逗号分隔(如 `linux/amd64,windows/amd64`,Lua 插件为 `lua`) |
|
| `targets` | string | 构建目标,逗号分隔(如 `linux/amd64,windows/amd64`,Lua 插件为 `lua`) |
|
||||||
| `outdir` | string | 输出目录(默认 `dist`) |
|
| `outdir` | string | 输出目录(默认 `dist`) |
|
||||||
@ -217,11 +374,14 @@ chmod +x plugindev
|
|||||||
`.hmap` 为 ZIP 归档,包含:
|
`.hmap` 为 ZIP 归档,包含:
|
||||||
|
|
||||||
- `plugin.json` — 插件元数据
|
- `plugin.json` — 插件元数据
|
||||||
- `plugin.so` — Go 编译产物(Linux)
|
- `plugin.bin` — Go 编译产物(单平台构建)
|
||||||
- `plugin.dll` — Go 编译产物(Windows)
|
- `plugin.bin.<goos>.<goarch>` — 多平台 bundle 模式下每平台一份,
|
||||||
- `plugin.dylib` — Go 编译产物(macOS,bundle 模式)
|
安装时 pluginmgr 挑当前平台那份重命名为 `plugin.bin`
|
||||||
- `main.lua` — Lua 插件入口(Lua 插件时)
|
- `main.lua` — Lua 插件入口(Lua 插件时)
|
||||||
|
|
||||||
|
> v1.0.0 起不再使用 `plugin.so`/`plugin.dll`/`plugin.dylib`——进程边界即 ABI 边界,
|
||||||
|
> 不存在平台特定的动态库区分。旧产物新内核不会加载,会给出明确的重编提示。
|
||||||
|
|
||||||
## 插件生命周期
|
## 插件生命周期
|
||||||
|
|
||||||
### 入口函数
|
### 入口函数
|
||||||
@ -259,7 +419,7 @@ return plugin
|
|||||||
|
|
||||||
- `sdk.RegisterOnRemoveHandler(fn func())` — 注册删除清理回调。内核在 `RemovePlugin` 流程中、插件 `Stop()` **之后**执行(后注册先执行,执行后清空、幂等)。用于删除插件自身创建的持久化文件(数据/缓存/状态文件)。
|
- `sdk.RegisterOnRemoveHandler(fn func())` — 注册删除清理回调。内核在 `RemovePlugin` 流程中、插件 `Stop()` **之后**执行(后注册先执行,执行后清空、幂等)。用于删除插件自身创建的持久化文件(数据/缓存/状态文件)。
|
||||||
- 内核卸载时一并清理:工具注册、`disabled_plugins` 记录、插件配置项定义(`plugin.<name>.*`)与插件配置表(`config_<name>`),卸载后插件配置区完全消失。
|
- 内核卸载时一并清理:工具注册、`disabled_plugins` 记录、插件配置项定义(`plugin.<name>.*`)与插件配置表(`config_<name>`),卸载后插件配置区完全消失。
|
||||||
- 示例:`example/calendar`(删 events.json)、`example/memo`(删 memos.json)、`example/rss`(删订阅数据目录)、`example/weather`(删缓存目录);`plugindev` 模板含 onRemove 演示。
|
- 示例:`example/calendar`(删 events.json)、`example/memo`(删 memos.json)、`example/rss`(删订阅数据目录)、`example/weather`(删缓存目录);`hmapdev` 模板含 onRemove 演示。
|
||||||
|
|
||||||
```go
|
```go
|
||||||
sdk.RegisterOnRemoveHandler(func() {
|
sdk.RegisterOnRemoveHandler(func() {
|
||||||
@ -277,6 +437,31 @@ enabled := sdk.AutoRestart()
|
|||||||
|
|
||||||
插件崩溃时平台自动拉起,保障服务可用性。
|
插件崩溃时平台自动拉起,保障服务可用性。
|
||||||
|
|
||||||
|
> ⚠️ `SetAutoRestart` 的典型用法是「外部连接建好后再判定能否自动重启」,而连接建立
|
||||||
|
> 通常在后台 goroutine 里,内核又在另一个 goroutine 读它——这对读写天然并发。
|
||||||
|
> **SDK 1.1.0 已给这个标志与全部 API 字段加锁**(`-race` 实测 11 处竞态,
|
||||||
|
> 生产表现是插件重载瞬间偶发 nil 解引用崩溃)。早于 1.1.0 的版本建议升级。
|
||||||
|
|
||||||
|
## 插件开发者的并发约定
|
||||||
|
|
||||||
|
`PluginSDK` 是**被多个 goroutine 同时使用的共享对象**:你在 `Start()` 里起的轮询、
|
||||||
|
监听、定时器都拿着同一份 `*PluginSDK` 往里注消息,而内核会在加载/重载时写它的
|
||||||
|
API 字段。因此:
|
||||||
|
|
||||||
|
- **SDK 侧已保证的**:全部 API 访问器(`Memory()`/`DocMemory()`/…)、全部注入方法、
|
||||||
|
`SetAutoRestart`/`AutoRestart`、`RegisterTool`/`RegisterStage`、
|
||||||
|
`RunStopHandlers`/`RunOnRemoveHandlers`(幂等,并发调也只执行一次)。
|
||||||
|
- **你需要自己保证的**:`StageContext` 的字段全部导出,并发读写必须自己持
|
||||||
|
`ctx.Lock()`/`ctx.RLock()`。尤其是 `ctx.Extra`——**map 的并发写在 Go 里是直接 fatal,
|
||||||
|
`recover` 接不住**。
|
||||||
|
|
||||||
|
```go
|
||||||
|
ctx.Lock()
|
||||||
|
ctx.Extra["mykey"] = value
|
||||||
|
ctx.FinalText += "补充说明"
|
||||||
|
ctx.Unlock()
|
||||||
|
```
|
||||||
|
|
||||||
## 受限 SDK vs 完整 SDK
|
## 受限 SDK vs 完整 SDK
|
||||||
|
|
||||||
外部插件(第三方分发)使用**受限 SDK**,仅暴露安全子集:
|
外部插件(第三方分发)使用**受限 SDK**,仅暴露安全子集:
|
||||||
@ -288,6 +473,57 @@ enabled := sdk.AutoRestart()
|
|||||||
|
|
||||||
内部插件(平台内置)拥有完整 SDK 访问权限,包括 SocialAPI 写操作和 EventPublisher。
|
内部插件(平台内置)拥有完整 SDK 访问权限,包括 SocialAPI 写操作和 EventPublisher。
|
||||||
|
|
||||||
|
## 项目声明 SDK 版本(plg.json 的 `sdk` 字段)
|
||||||
|
|
||||||
|
`hmapdev init` 生成的工程里,`plg.json` 会带一个 `sdk` 字段:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "MyPlugin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"entry": "plugin.bin",
|
||||||
|
"sdk": "1.2.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
它的语义是**本插件针对的 SDK 版本**,工具链据此在本地 SDK 存储里选择版本:
|
||||||
|
命中就用它,并把 `go.mod` 的 `require`/`replace` 同步到该版本;未命中则**明确报错**
|
||||||
|
(列出已装版本 + `hmapdev sdk install vX.Y.Z`),**绝不静默退化成 `current`**。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ hmapdev build
|
||||||
|
[hmapdev] SDK 1.2.0(项目声明 sdk=1.2.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
为什么要这个字段:以前项目里没有任何「我要哪版 SDK」的声明,工具链只能用存储里的
|
||||||
|
`current`——谁改过 `current` 就拿谁的版本编,出错时表现为一堆看不懂的编译错误
|
||||||
|
(例如存储里只有陈旧的 `v0.8.0` 时,模板项目首次构建会报 `undefined: sdk.InjectOptions`)。
|
||||||
|
|
||||||
|
**写法必须是完整版本号(`1.2.0`),不接受区间写法(`1.2`)。** 原因见上文的版本纪律:
|
||||||
|
SDK 版本跟随内核中版本、patch 位恒为 `.0`,一条内核线只对应一个 SDK 版本;
|
||||||
|
写区间会让人误以为同一条线里还能挑不同 SDK(工具链会直接拒绝并说明这条规矩)。
|
||||||
|
|
||||||
|
- 显式 `--sdk-path` 或 `plg.json` 的 `sdk_path` 优先(本机改 SDK 联调时用);
|
||||||
|
- 存量工程(`plg.json` 没有 `sdk` 字段)行为不变,仍按 `current` 构建;
|
||||||
|
- 产物 `.hmap` 里的 `plugin.json` 会记录**实际选中的 SDK 版本**,便于事后追溯。
|
||||||
|
|
||||||
|
## IDE 支持:VSCode 扩展(`tools/vscode-hmapdev`)
|
||||||
|
|
||||||
|
调试插件的实操回路是「构建 → 运行 → 看内核日志」,这三步都在 IDE 之外很别扭,
|
||||||
|
所以仓库里带了一个 VSCode 扩展([tools/vscode-hmapdev](tools/vscode-hmapdev)):
|
||||||
|
|
||||||
|
- **plg.json 诊断**:必需字段、`sdk` 是否是完整版本号、声明的 SDK 是否已安装(直接给安装命令);
|
||||||
|
- **状态栏**:`插件 · SDK <声明> · hmapdev <版本>`,工具链缺失或工程有错时变色;
|
||||||
|
- **命令 / 任务**:build / build(全部目标)/ clean / debug(解释执行),编译错误进 Problems;
|
||||||
|
- **跟随内核日志**:读 `<dataDir>/log` 下最新的 `homed_*.log` 并按插件名过滤。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/vscode-hmapdev && npm install && npm run compile # 然后在 VSCode 里按 F5
|
||||||
|
```
|
||||||
|
|
||||||
|
它不是源码级调试器(没有断点/单步):插件要么编译成产物在内核里跑、要么用
|
||||||
|
`hmapdev debug` 解释执行,两条路都没有 DAP 会话;扩展做的是构建、运行、看日志与清单校验。
|
||||||
|
|
||||||
## 示例插件
|
## 示例插件
|
||||||
|
|
||||||
| 插件 | 类型 | 说明 |
|
| 插件 | 类型 | 说明 |
|
||||||
@ -308,6 +544,11 @@ enabled := sdk.AutoRestart()
|
|||||||
| [rss](example/rss) | Go | RSS 订阅 |
|
| [rss](example/rss) | Go | RSS 订阅 |
|
||||||
| [sanitizer](example/sanitizer) | Go | 内容清洗/安全过滤 |
|
| [sanitizer](example/sanitizer) | Go | 内容清洗/安全过滤 |
|
||||||
|
|
||||||
|
**发版时附带预编译示例产物**:SDK 的 release 除 5 平台 `hmapdev` 外,还包含各示例插件的
|
||||||
|
`.hmap` 与 `SHA256SUMS`/`MANIFEST.txt`。原因是插件二进制与内核**协议绑定**(`ProtocolVersion`
|
||||||
|
+ 共享内存区魔数),只发工具链不发示例产物,很容易拿旧产物去装而握手失败——那看起来像
|
||||||
|
「插件坏了」而不是「版本不配套」。
|
||||||
|
|
||||||
## Remote Device SDK
|
## Remote Device SDK
|
||||||
|
|
||||||
用于开发**远程设备接入适配器**的 C 语言 SDK,零外部依赖,兼容嵌入式平台。
|
用于开发**远程设备接入适配器**的 C 语言 SDK,零外部依赖,兼容嵌入式平台。
|
||||||
@ -409,10 +650,10 @@ ha_transport_t my_transport = {
|
|||||||
|
|
||||||
### 使用方式
|
### 使用方式
|
||||||
|
|
||||||
通过 `plugindev` 工具链初始化项目:
|
通过 `hmapdev` 工具链初始化项目:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
plugindev init my-adapter --type remotedevice
|
hmapdev init my-adapter --type remotedevice
|
||||||
```
|
```
|
||||||
|
|
||||||
生成 `main.c` + `CMakeLists.txt`,可直接编译或作为三方库引入:
|
生成 `main.c` + `CMakeLists.txt`,可直接编译或作为三方库引入:
|
||||||
@ -624,17 +865,17 @@ curl -X POST http://<homeagent-server>:8080/api/v1/device/esp32-cam-1/cmd \
|
|||||||
### 位置
|
### 位置
|
||||||
|
|
||||||
- **SDK 源码**: `remotedevice/`
|
- **SDK 源码**: `remotedevice/`
|
||||||
- **plugindev 模板**: `plugindev init --type remotedevice`
|
- **hmapdev 模板**: `hmapdev init --type remotedevice`
|
||||||
|
|
||||||
## 构建与安装
|
## 构建与安装
|
||||||
|
|
||||||
### 构建
|
### 构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
plugindev build
|
hmapdev build
|
||||||
```
|
```
|
||||||
|
|
||||||
输出 `.hmap` 包到 `dist/` 目录(默认 bundle 多平台合集;单平台构建使用 `plugindev build --no-bundle`)。
|
输出 `.hmap` 包到 `dist/` 目录(默认 bundle 多平台合集;单平台构建使用 `hmapdev build --no-bundle`)。
|
||||||
|
|
||||||
### 安装
|
### 安装
|
||||||
|
|
||||||
@ -652,3 +893,16 @@ curl -X POST http://127.0.0.1:9876/plugins \
|
|||||||
```
|
```
|
||||||
|
|
||||||
或通过 WebUI 插件管理页面上传,也可手动将 `.hmap` 放入插件目录后重启平台。
|
或通过 WebUI 插件管理页面上传,也可手动将 `.hmap` 放入插件目录后重启平台。
|
||||||
|
|
||||||
|
## 许可
|
||||||
|
|
||||||
|
SDK 以 **AGPL-3.0-only** 发布,全文见 [LICENSE](LICENSE)。
|
||||||
|
|
||||||
|
**这对插件开发者是实质性约束**:SDK 会随插件一起**静态链接**(其源码进入插件二进制),
|
||||||
|
插件因此是本 SDK 的衍生作品,**必须以相同许可(AGPL-3.0-only)发布**;并且因为 AGPL §13
|
||||||
|
覆盖网络交互,通过 HTTP/WebSocket 等向用户提供服务的插件同样要向使用者提供源码。
|
||||||
|
若你的插件需要闭源,唯一合规路径是另行取得本项目的例外/商业授权——目前不提供。
|
||||||
|
|
||||||
|
第三方组件(Go 依赖:go-sqlite3、gojieba、bubbletea 等,均为 MIT / BSD-3 / Apache-2.0)
|
||||||
|
保持各自原有许可。平台侧的模型与推理运行时(Chinese-CLIP Apache-2.0、ONNX Runtime MIT)
|
||||||
|
不属于本 SDK,其许可全文随发行包放在 `/usr/share/doc/homeagent/licenses/`。
|
||||||
|
|||||||
271
README_EN.md
271
README_EN.md
@ -2,6 +2,83 @@
|
|||||||
|
|
||||||
Plugin development SDK for building intelligent plugins that interact with the HomeAgent platform.
|
Plugin development SDK for building intelligent plugins that interact with the HomeAgent platform.
|
||||||
|
|
||||||
|
## Version and Compatibility
|
||||||
|
|
||||||
|
Current: **SDK 1.2.0** (requires kernel **1.2.0+**).
|
||||||
|
|
||||||
|
**The version tracks the kernel's minor version, with the patch position pinned at `.0`**:
|
||||||
|
|
||||||
|
| Kernel version | Matching SDK |
|
||||||
|
|---|---|
|
||||||
|
| 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 |
|
||||||
|
| 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** |
|
||||||
|
| 1.2.0 onward | 1.2.0 |
|
||||||
|
|
||||||
|
The kernel's patch position is reserved for bugfixes and vulnerability fixes, which never touch the
|
||||||
|
public interface, so the SDK version has no reason to move with it — otherwise you would either be
|
||||||
|
forced to chase releases or suspect your version is stale, when not one character of the interface
|
||||||
|
has changed.
|
||||||
|
|
||||||
|
**Upgrading a 1.0.x plugin to 1.1.x: no code changes, no rebuild.** Everything added in 1.1.0 is
|
||||||
|
in the "plugin calls, kernel implements" direction, so not calling it means not being affected
|
||||||
|
(verified with an old `plugin.bin` built against SDK 0.9.2: it handshakes fine on the new kernel,
|
||||||
|
because the handshake validates `ProtocolVersion`, not the SDK version). Rebuild only when you want
|
||||||
|
the new fields.
|
||||||
|
|
||||||
|
**Upgrading a 1.1.x plugin to 1.2.x: the interface is purely additive, but a rebuild is required.**
|
||||||
|
No public signature changed (the SDK adds `InjectOptions`, six `*Opts` variants and
|
||||||
|
`ChannelDef.ContextPolicy`), so not calling the new capabilities means not being affected — but the
|
||||||
|
kernel's **plugin protocol went to 2** (the fd3 layout of the unified shared-memory region changed,
|
||||||
|
and **rolling upgrades are not supported**). `plugin.bin` must therefore be rebuilt with the matching
|
||||||
|
`hmapdev` and installed **together with** the kernel; otherwise the handshake fails on protocol
|
||||||
|
version mismatch (the error says explicitly to rebuild with the matching hmapdev — it never
|
||||||
|
degrades silently).
|
||||||
|
|
||||||
|
## Injection Behaviour and Context Pruning (1.2.0)
|
||||||
|
|
||||||
|
"Should this go into memory" and "should the context be pruned based on this" used to be
|
||||||
|
something only `ToolDef` could declare. Since 1.2.0 **injections can declare them too**, sharing
|
||||||
|
the same semantics and values.
|
||||||
|
|
||||||
|
```go
|
||||||
|
type InjectOptions struct {
|
||||||
|
NoMemory bool // true = excluded from memory computation (vectorize/keywords/distill); the
|
||||||
|
// original text still stays in context
|
||||||
|
ContextPolicy string // ""/none = do not prune (default); prune = prune context based on this
|
||||||
|
CleanerName string // name of the compute-layer cleaner: run it first to get the effective
|
||||||
|
// content, then compute/prune on that
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
ContextPolicyNone = "none"
|
||||||
|
ContextPolicyPrune = "prune"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Six variants, one-to-one with the older three-argument methods, plus opts
|
||||||
|
InjectTextOpts(source, channel, text string, opts InjectOptions)
|
||||||
|
InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
|
||||||
|
InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
|
||||||
|
InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||||
|
InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
|
||||||
|
InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
|
||||||
|
- **A zero-valued `InjectOptions{}` is key-for-key equivalent to the older three-argument methods**
|
||||||
|
(recorded in memory, not pruned). The old methods remain as zero-value sugar (`InjectText`,
|
||||||
|
`InjectInterruptText`, `InjectTextNoMemory`, …), so existing plugins keep working without a single
|
||||||
|
line changed *or* a rebuild.
|
||||||
|
- **Pruning (`prune`) must be declared explicitly**: it archives/drops low-relevance events, which
|
||||||
|
is a side effect, so it is off by default. The kernel only accepts `""` / `none` / `prune`
|
||||||
|
(`ValidContextPolicy`); anything else is rejected.
|
||||||
|
- Pruning first goes through the plugin's registered **`Cleaner`** (named by `CleanerName`) to get
|
||||||
|
the effective content, avoiding the inconsistency of "prune on the raw text, compute on the
|
||||||
|
cleaned text".
|
||||||
|
- `ChannelDef` carries the same `context_policy` (1.2.0 also gave `ChannelDef` JSON tags — the
|
||||||
|
definition crosses the process boundary, while `Cleaner` is a function that must be ignored; with
|
||||||
|
no tags, newly added fields would be silently dropped).
|
||||||
|
|
||||||
## SDK API Surface
|
## SDK API Surface
|
||||||
|
|
||||||
### Plugin Interface
|
### Plugin Interface
|
||||||
@ -36,6 +113,7 @@ The SDK instance injected via `Start(sdk *PluginSDK)` provides:
|
|||||||
| Settings | `Settings()` | Access settings API |
|
| Settings | `Settings()` | Access settings API |
|
||||||
| Events | `Events()` | Access event subscriber (subscribe-only for external plugins) |
|
| Events | `Events()` | Access event subscriber (subscribe-only for external plugins) |
|
||||||
| Inject | `InjectText(source, channel, text)` / `InjectInterruptText(source, channel, text)` / `InjectTextNoMemory(source, channel, text)` | Inject text into the agent pipeline |
|
| Inject | `InjectText(source, channel, text)` / `InjectInterruptText(source, channel, text)` / `InjectTextNoMemory(source, channel, text)` | Inject text into the agent pipeline |
|
||||||
|
| Media inject | `InjectInputMedia(source, channel, text, blocks)` / `InjectInputMediaSync(...)` / `InjectInterruptMedia(...)` | Inject input carrying images/audio (added in 1.1.0) |
|
||||||
| Auto-Restart | `SetAutoRestart(enabled)` / `AutoRestart()` | Control automatic restart on crash |
|
| Auto-Restart | `SetAutoRestart(enabled)` / `AutoRestart()` | Control automatic restart on crash |
|
||||||
|
|
||||||
### Stage Hooks
|
### Stage Hooks
|
||||||
@ -106,6 +184,32 @@ Type enum values:
|
|||||||
| `InjectInterruptText(source, channel, text)` | Inject interrupt text, interrupt current processing, route to specified channel |
|
| `InjectInterruptText(source, channel, text)` | Inject interrupt text, interrupt current processing, route to specified channel |
|
||||||
| `InjectTextNoMemory(source, channel, text)` | Inject text without memory recording, route to specified channel |
|
| `InjectTextNoMemory(source, channel, text)` | Inject text without memory recording, route to specified channel |
|
||||||
|
|
||||||
|
### Multimodal Injection (added in 1.1.0)
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `InjectInputMedia(source, channel, text, blocks)` | Inject media-bearing input, asynchronous |
|
||||||
|
| `InjectInputMediaSync(source, channel, text, blocks)` | Inject media-bearing input and wait for the reply text |
|
||||||
|
| `InjectInterruptMedia(source, channel, text, blocks)` | Inject a media-bearing interrupt that can preempt current processing |
|
||||||
|
|
||||||
|
`blocks` is `[]sdk.ContentBlock`, the same type `SetToolBlocks` takes:
|
||||||
|
|
||||||
|
```go
|
||||||
|
s.InjectInputMedia("myplugin", "webui", "take a look at this", []sdk.ContentBlock{{
|
||||||
|
Type: "image_url",
|
||||||
|
ImageURL: &sdk.ImageURL{URL: "data:image/png;base64," + b64, Detail: "auto"},
|
||||||
|
}})
|
||||||
|
```
|
||||||
|
|
||||||
|
How this differs from `SetToolBlocks`: that one is only callable inside a tool handler and
|
||||||
|
its media reaches the model with the *next* tool message. These three let a plugin
|
||||||
|
**initiate a turn that carries media** — the media goes out with this turn's message and is
|
||||||
|
automatically stored in the media store with a memory reference attached.
|
||||||
|
|
||||||
|
`data:` URLs in the blocks are stored and deduplicated by the kernel; `http(s)` URLs are
|
||||||
|
passed to the model only and never stored (storing them would require the kernel to make
|
||||||
|
network requests, bringing timeouts, auth and SSRF into scope).
|
||||||
|
|
||||||
`source` identifies the origin, `channel` specifies the target output channel.
|
`source` identifies the origin, `channel` specifies the target output channel.
|
||||||
|
|
||||||
### Triple Extended Fields
|
### Triple Extended Fields
|
||||||
@ -115,6 +219,65 @@ The Triple data structure includes additional fields:
|
|||||||
- `Confidence` — confidence score (0.0–1.0)
|
- `Confidence` — confidence score (0.0–1.0)
|
||||||
- `SubjectType` — subject type
|
- `SubjectType` — subject type
|
||||||
- `ObjectType` — object type
|
- `ObjectType` — object type
|
||||||
|
- `SentenceText` — the original sentence (added in 1.1.0), written to the `sentences` table; media references hang off the sentence
|
||||||
|
- `MediaDigests` — associated media digests (added in 1.1.0)
|
||||||
|
|
||||||
|
### Media in Memory (added in 1.1.0)
|
||||||
|
|
||||||
|
Inside plain-text memory, media is represented as a **marker** of the form
|
||||||
|
`[<mime> <short digest>] <description>`:
|
||||||
|
|
||||||
|
```
|
||||||
|
[image/png a1b2c3d4e5f6] a purple-blue-red three-band chart
|
||||||
|
```
|
||||||
|
|
||||||
|
The description is the durable semantic memory (retrieval uses it); the digest is the key
|
||||||
|
back to the bytes (reverse lookup uses it). Markers are generated by the kernel — a plugin
|
||||||
|
never has to assemble one, it just **supplies the digest**.
|
||||||
|
|
||||||
|
#### Graph memory
|
||||||
|
|
||||||
|
```go
|
||||||
|
s.Memory().Commit([]sdk.Triple{{
|
||||||
|
Subject: "palette", Relation: "contains", Object: "three-band",
|
||||||
|
MediaDigests: []string{"a1b2c3d4e5f6"}, // short digest is fine, the kernel resolves it
|
||||||
|
}})
|
||||||
|
```
|
||||||
|
|
||||||
|
With no `SentenceText`, the kernel uses the marker itself as the sentence — media must have
|
||||||
|
a sentence to hang off, otherwise the reference has nowhere to attach.
|
||||||
|
|
||||||
|
#### Knowledge base
|
||||||
|
|
||||||
|
```go
|
||||||
|
s.DocMemory().InsertWithMedia(&sdk.Doc{
|
||||||
|
Title: "illustrated note",
|
||||||
|
Content: "body",
|
||||||
|
}, []sdk.MediaAttachment{
|
||||||
|
{MIME: "image/png", Data: pngBytes, Name: "chart.png"}, // new content, stored and deduped
|
||||||
|
{Digest: "a1b2c3d4e5f6"}, // reference existing content
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`Insert` keeps its original signature; markers already present in the body are bound as
|
||||||
|
document-level references too. `Query` fills `MediaDigests` and `Attachments` (mime plus
|
||||||
|
description, **no bytes** — one query can match dozens of media items). Removing a document
|
||||||
|
releases its references.
|
||||||
|
|
||||||
|
#### Text memory
|
||||||
|
|
||||||
|
```go
|
||||||
|
s.TextMemory().Append(sdk.TextEvent{
|
||||||
|
Role: "user", Content: "look at this",
|
||||||
|
Attachments: []sdk.MediaAttachment{{MIME: "image/png", Data: pngBytes}},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`RecentEvents` decodes markers in the body back into `Attachments`.
|
||||||
|
|
||||||
|
The media store can be disabled kernel-side (`core.memory.media.enabled=false`); all of the
|
||||||
|
above then degrades to plain-text behaviour — no errors, no panics, identical to how it
|
||||||
|
behaved before this feature shipped.
|
||||||
|
|
||||||
### ToolDef Field Reference
|
### ToolDef Field Reference
|
||||||
|
|
||||||
@ -140,16 +303,37 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
|
|||||||
|
|
||||||
Plugin developers only need to implement the `Plugin` interface and export a `NewPlugin()` entry function.
|
Plugin developers only need to implement the `Plugin` interface and export a `NewPlugin()` entry function.
|
||||||
|
|
||||||
## plugindev Toolchain
|
## hmapdev Toolchain
|
||||||
|
|
||||||
`plugindev` provides full development workflow support:
|
`hmapdev` provides full development workflow support and produces `.hmap` plugin bundles (the tool is
|
||||||
|
named after that package format). Prebuilt binaries ship as **release assets**
|
||||||
|
(linux/darwin/windows × amd64/arm64); download from
|
||||||
|
[Releases](https://gitcode.com/JianFeeeee/homeagent-sdk/releases) and put it on your PATH:
|
||||||
|
|
||||||
|
> Rename note: the toolchain was called `plugindev` and is `hmapdev` since 1.2.0.
|
||||||
|
> The SDK store moved from `~/.homeagent/plugindev/sdk` to `~/.homeagent/hmapdev/sdk`
|
||||||
|
> (the old directory is still honored, so installed versions are not lost).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From release assets (latest SDK release / linux amd64 shown)
|
||||||
|
curl -Lo hmapdev https://gitcode.com/JianFeeeee/homeagent-sdk/releases/download/<version>/hmapdev_linux_amd64
|
||||||
|
chmod +x hmapdev
|
||||||
|
|
||||||
|
# Or build from source
|
||||||
|
cd tools/hmapdev && go build -o hmapdev .
|
||||||
|
```
|
||||||
|
|
||||||
|
> Binaries no longer ship inside the repository (the old `bin/` directory is retired): five
|
||||||
|
> platforms at 26-28MB each piled another copy into git history on every rebuild, and they are
|
||||||
|
> reproducible from source anyway.
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `plugindev init` | Initialize plugin project (generates plg.json, entry template) |
|
| `hmapdev init <name> [--lua]` | Initialize plugin project (generates plg.json, plugin.go or main.lua, go.mod, README.md) |
|
||||||
| `plugindev build` | Build plugin, output .hmap package |
|
| `hmapdev build [flags]` | Build and package into a `.hmap` (supports cross-compilation and bundle mode) |
|
||||||
| `plugindev clean` | Clean build artifacts |
|
| `hmapdev clean` | Clean `build/` and `dist/` plus generated files |
|
||||||
| `plugindev debug` | Run plugin in local debug mode |
|
| `hmapdev debug [dir]` | Load plugin source through the Yaegi Go interpreter and start an interactive REPL |
|
||||||
|
| `hmapdev sdk <command>` | SDK version management (list/install/use/path/current/latest) |
|
||||||
|
|
||||||
Supports both **Go** and **Lua** plugin languages.
|
Supports both **Go** and **Lua** plugin languages.
|
||||||
|
|
||||||
@ -163,7 +347,7 @@ Supports both **Go** and **Lua** plugin languages.
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Weather plugin",
|
"description": "Weather plugin",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.bin",
|
||||||
"tags": ["weather", "forecast"],
|
"tags": ["weather", "forecast"],
|
||||||
"targets": "linux/amd64,windows/amd64",
|
"targets": "linux/amd64,windows/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
@ -185,7 +369,7 @@ Supports both **Go** and **Lua** plugin languages.
|
|||||||
| `version` | string | Version |
|
| `version` | string | Version |
|
||||||
| `description` | string | Plugin description |
|
| `description` | string | Plugin description |
|
||||||
| `author` | string | Author |
|
| `author` | string | Author |
|
||||||
| `entry` | string | Entry file (`plugin.so` / `main.lua`) |
|
| `entry` | string | Entry file (`plugin.bin` / `main.lua`). Since v1.0.0 Go plugins uniformly build to `plugin.bin`—no per-platform suffix |
|
||||||
| `tags` | string[] | Tags |
|
| `tags` | string[] | Tags |
|
||||||
| `targets` | string | Build targets, comma-separated (e.g. `linux/amd64,windows/amd64`) |
|
| `targets` | string | Build targets, comma-separated (e.g. `linux/amd64,windows/amd64`) |
|
||||||
| `outdir` | string | Output directory (default `dist`) |
|
| `outdir` | string | Output directory (default `dist`) |
|
||||||
@ -198,10 +382,15 @@ Supports both **Go** and **Lua** plugin languages.
|
|||||||
`.hmap` is a ZIP archive containing:
|
`.hmap` is a ZIP archive containing:
|
||||||
|
|
||||||
- `plugin.json` — plugin metadata
|
- `plugin.json` — plugin metadata
|
||||||
- `plugin.so` — Go compiled artifact (Linux)
|
- `plugin.bin` — Go compiled artifact (single-platform build)
|
||||||
- `plugin.dll` — Go compiled artifact (Windows)
|
- `plugin.bin.<goos>.<goarch>` — one per platform in bundle mode; on install pluginmgr picks
|
||||||
|
the one matching the current platform and renames it to `plugin.bin`
|
||||||
- `main.lua` — Lua plugin entry (for Lua plugins)
|
- `main.lua` — Lua plugin entry (for Lua plugins)
|
||||||
|
|
||||||
|
> Since v1.0.0 `plugin.so`/`plugin.dll`/`plugin.dylib` are no longer used—the process boundary
|
||||||
|
> *is* the ABI boundary, so there is no platform-specific shared-library distinction. The new
|
||||||
|
> kernel will not load old artifacts; it emits an explicit rebuild hint instead.
|
||||||
|
|
||||||
## Plugin Lifecycle
|
## Plugin Lifecycle
|
||||||
|
|
||||||
### Start & Stop
|
### Start & Stop
|
||||||
@ -216,7 +405,7 @@ Supports both **Go** and **Lua** plugin languages.
|
|||||||
|
|
||||||
- `sdk.RegisterOnRemoveHandler(fn func())` — Register a remove cleanup callback. The kernel runs it **after** the plugin's `Stop()` in the `RemovePlugin` flow (LIFO order, cleared after running — idempotent). Use it to delete persistent files the plugin created itself (data/cache/state files).
|
- `sdk.RegisterOnRemoveHandler(fn func())` — Register a remove cleanup callback. The kernel runs it **after** the plugin's `Stop()` in the `RemovePlugin` flow (LIFO order, cleared after running — idempotent). Use it to delete persistent files the plugin created itself (data/cache/state files).
|
||||||
- The kernel also cleans up on uninstall: tool registrations, the `disabled_plugins` record, the plugin's config definitions (`plugin.<name>.*`) and its config table (`config_<name>`) — the plugin's config section disappears completely after removal.
|
- The kernel also cleans up on uninstall: tool registrations, the `disabled_plugins` record, the plugin's config definitions (`plugin.<name>.*`) and its config table (`config_<name>`) — the plugin's config section disappears completely after removal.
|
||||||
- Examples: `example/calendar` (removes events.json), `example/memo` (removes memos.json), `example/rss` (removes the subscription data dir), `example/weather` (removes the cache dir); the `plugindev` template includes an onRemove demo.
|
- Examples: `example/calendar` (removes events.json), `example/memo` (removes memos.json), `example/rss` (removes the subscription data dir), `example/weather` (removes the cache dir); the `hmapdev` template includes an onRemove demo.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
sdk.RegisterOnRemoveHandler(func() {
|
sdk.RegisterOnRemoveHandler(func() {
|
||||||
@ -234,6 +423,33 @@ enabled := sdk.AutoRestart()
|
|||||||
|
|
||||||
The platform automatically restarts the plugin on crash, ensuring service availability.
|
The platform automatically restarts the plugin on crash, ensuring service availability.
|
||||||
|
|
||||||
|
> ⚠️ `SetAutoRestart` is typically used to decide whether auto-restart is safe *after* an
|
||||||
|
> external connection has been established, and that connection setup usually happens in a
|
||||||
|
> background goroutine while the kernel reads the flag from another one — which is inherently
|
||||||
|
> concurrent. **SDK 1.1.0 locks this flag and all API fields** (`-race` reported 11 data races;
|
||||||
|
> in production this showed up as sporadic nil-dereference crashes during plugin reload). Upgrade
|
||||||
|
> if you are on anything earlier.
|
||||||
|
|
||||||
|
## Concurrency Contract for Plugin Developers
|
||||||
|
|
||||||
|
`PluginSDK` is a **shared object used by multiple goroutines**: the polling, listening and timer
|
||||||
|
callbacks you start in `Start()` all hold the same `*PluginSDK` and push messages into it, while
|
||||||
|
the kernel writes its API fields during load/reload. So:
|
||||||
|
|
||||||
|
- **Guaranteed by the SDK**: all API accessors (`Memory()`/`DocMemory()`/…), all injection methods,
|
||||||
|
`SetAutoRestart`/`AutoRestart`, `RegisterTool`/`RegisterStage`, and
|
||||||
|
`RunStopHandlers`/`RunOnRemoveHandlers` (idempotent; concurrent calls still run it once).
|
||||||
|
- **Your responsibility**: every field of `StageContext` is exported, and concurrent read/write
|
||||||
|
must hold `ctx.Lock()`/`ctx.RLock()`. Especially `ctx.Extra` — **concurrent map writes are a
|
||||||
|
fatal in Go, and `recover` cannot catch it**.
|
||||||
|
|
||||||
|
```go
|
||||||
|
ctx.Lock()
|
||||||
|
ctx.Extra["mykey"] = value
|
||||||
|
ctx.FinalText += "supplementary note"
|
||||||
|
ctx.Unlock()
|
||||||
|
```
|
||||||
|
|
||||||
## Restricted SDK vs Full SDK
|
## Restricted SDK vs Full SDK
|
||||||
|
|
||||||
External plugins (third-party distribution) use a **restricted SDK** that only exposes a safe subset:
|
External plugins (third-party distribution) use a **restricted SDK** that only exposes a safe subset:
|
||||||
@ -265,6 +481,13 @@ Internal plugins (platform built-in) have full SDK access including SocialAPI wr
|
|||||||
| [rss](example/rss) | Go | RSS subscriptions |
|
| [rss](example/rss) | Go | RSS subscriptions |
|
||||||
| [sanitizer](example/sanitizer) | Go | Content sanitization / safety filtering |
|
| [sanitizer](example/sanitizer) | Go | Content sanitization / safety filtering |
|
||||||
|
|
||||||
|
**Prebuilt example artifacts ship with every release**: besides the 5-platform `hmapdev`, an SDK
|
||||||
|
release contains the example plugins' `.hmap` files plus `SHA256SUMS`/`MANIFEST.txt`. The reason is
|
||||||
|
that plugin binaries are **protocol-bound** to the kernel (`ProtocolVersion` + the shared-memory
|
||||||
|
magic), so shipping the toolchain without matching artifacts invites installing an old artifact —
|
||||||
|
which fails the handshake and looks like "the plugin is broken" rather than "the versions don't
|
||||||
|
match".
|
||||||
|
|
||||||
## Remote Device SDK
|
## Remote Device SDK
|
||||||
|
|
||||||
A C language SDK for developing **remote device access adapters** with zero external dependencies, compatible with embedded platforms.
|
A C language SDK for developing **remote device access adapters** with zero external dependencies, compatible with embedded platforms.
|
||||||
@ -349,10 +572,10 @@ ha_transport_t my_transport = {
|
|||||||
|
|
||||||
### Usage
|
### Usage
|
||||||
|
|
||||||
Initialize a project via the `plugindev` toolchain:
|
Initialize a project via the `hmapdev` toolchain:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
plugindev init my-adapter --type remotedevice
|
hmapdev init my-adapter --type remotedevice
|
||||||
```
|
```
|
||||||
|
|
||||||
Generates `main.c` + `CMakeLists.txt`, can be built directly or used as a third-party library:
|
Generates `main.c` + `CMakeLists.txt`, can be built directly or used as a third-party library:
|
||||||
@ -562,17 +785,17 @@ curl -X POST http://<homeagent-server>:8080/api/v1/device/esp32-cam-1/cmd \
|
|||||||
### Location
|
### Location
|
||||||
|
|
||||||
- **SDK Source**: `remotedevice/`
|
- **SDK Source**: `remotedevice/`
|
||||||
- **plugindev template**: `plugindev init --type remotedevice`
|
- **hmapdev template**: `hmapdev init --type remotedevice`
|
||||||
|
|
||||||
## Building & Installing
|
## Building & Installing
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
plugindev build
|
hmapdev build
|
||||||
```
|
```
|
||||||
|
|
||||||
Outputs a `.hmap` package to the `dist/` directory (default is the multi-platform bundle; use `plugindev build --no-bundle` for a single-target build).
|
Outputs a `.hmap` package to the `dist/` directory (default is the multi-platform bundle; use `hmapdev build --no-bundle` for a single-target build).
|
||||||
|
|
||||||
### Install
|
### Install
|
||||||
|
|
||||||
@ -590,3 +813,19 @@ curl -X POST http://127.0.0.1:9876/plugins \
|
|||||||
```
|
```
|
||||||
|
|
||||||
Or upload via the WebUI plugin management page, or manually place the `.hmap` in the plugin directory and restart the platform.
|
Or upload via the WebUI plugin management page, or manually place the `.hmap` in the plugin directory and restart the platform.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
The SDK is released under **AGPL-3.0-only** — see [LICENSE](LICENSE).
|
||||||
|
|
||||||
|
**This is a substantive constraint for plugin developers**: the SDK is **statically linked** into
|
||||||
|
your plugin (its source ends up in the plugin binary), so the plugin is a derivative work of
|
||||||
|
this SDK and **must be released under the same license**. Because AGPL §13 covers network
|
||||||
|
interaction, a plugin that serves users over HTTP/WebSocket must also offer them the source.
|
||||||
|
If you need a closed-source plugin, the only compliant route is a separate exception/commercial
|
||||||
|
license from this project — none is offered today.
|
||||||
|
|
||||||
|
Third-party components (Go dependencies: go-sqlite3, gojieba, bubbletea, … — MIT / BSD-3 /
|
||||||
|
Apache-2.0) keep their own licenses. The platform-side model and inference runtime
|
||||||
|
(Chinese-CLIP Apache-2.0, ONNX Runtime MIT) are not part of this SDK; their full license texts
|
||||||
|
ship with the release packages under `/usr/share/doc/homeagent/licenses/`.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -5,7 +5,7 @@ ai_image plugin
|
|||||||
## Build
|
## Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
plugindev build
|
hmapdev build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|||||||
@ -5,7 +5,7 @@ calendar plugin
|
|||||||
## Build
|
## Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
plugindev build
|
hmapdev build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|||||||
@ -520,7 +520,8 @@ func (p *Plugin) checkReminders() {
|
|||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
|
||||||
for _, msg := range injectMsgs {
|
for _, msg := range injectMsgs {
|
||||||
p.sdk.InjectInterruptText("calendar", "calendar", msg)
|
// NoMemory:日程到点提醒,不是记忆内容。
|
||||||
|
p.sdk.InjectInterruptTextOpts("calendar", "calendar", msg, sdk.InjectOptions{NoMemory: true})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ lua main.lua # 使用 sdk.lua mock,不依赖内核
|
|||||||
## 构建
|
## 构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
plugindev build
|
hmapdev build
|
||||||
```
|
```
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|||||||
@ -292,8 +292,9 @@ func (p *Plugin) periodicCheck() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if p.sdk != nil {
|
if p.sdk != nil {
|
||||||
p.sdk.InjectInterruptText(p.name, p.name,
|
// NoMemory:这是定时提醒,不是记忆内容。
|
||||||
fmt.Sprintf("注意,你还有%d条待办未完成,请检查", n))
|
p.sdk.InjectInterruptTextOpts(p.name, p.name,
|
||||||
|
fmt.Sprintf("注意,你还有%d条待办未完成,请检查", n), sdk.InjectOptions{NoMemory: true})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"name": "qq",
|
"name": "qq",
|
||||||
"name_zh": "QQ消息",
|
"name_zh": "QQ消息",
|
||||||
"name_en": "qq",
|
"name_en": "qq",
|
||||||
"version": "1.1.0",
|
"version": "1.4.0",
|
||||||
"description": "QQ 消息收发插件,通过 NapCat 协议桥接",
|
"description": "QQ 消息收发插件,通过 NapCat 协议桥接",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
204
example/qq/plugin_test.go
Normal file
204
example/qq/plugin_test.go
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newPermissionTestPlugin(t *testing.T) *Plugin {
|
||||||
|
t.Helper()
|
||||||
|
instance, err := NewPluginFactory("qq", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return instance.(*Plugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolCallContext(name string, args map[string]interface{}) *sdk.StageContext {
|
||||||
|
return &sdk.StageContext{ToolCalls: []sdk.ToolCall{{Name: name, Arguments: args}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnerBypassesQQPermissionBoundary(t *testing.T) {
|
||||||
|
p := newPermissionTestPlugin(t)
|
||||||
|
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
|
||||||
|
ctx := toolCallContext("calendar_list", nil)
|
||||||
|
if err := p.beforeToolcall(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ctx.Response != nil {
|
||||||
|
t.Fatalf("owner call rejected: %s", *ctx.Response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrivateResourceCannotBeAllowlisted(t *testing.T) {
|
||||||
|
p := newPermissionTestPlugin(t)
|
||||||
|
p.privateToolAllowlist = append(p.privateToolAllowlist, "calendar_*")
|
||||||
|
p.auth = qqAuthContext{active: true, userID: 10001}
|
||||||
|
ctx := toolCallContext("calendar_list", nil)
|
||||||
|
if err := p.beforeToolcall(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ctx.Response == nil || !strings.Contains(*ctx.Response, "私人资源工具") {
|
||||||
|
t.Fatalf("expected private-resource denial, got %#v", ctx.Response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNonOwnerQQHistoryIsScopedToCurrentGroup(t *testing.T) {
|
||||||
|
p := newPermissionTestPlugin(t)
|
||||||
|
p.auth = qqAuthContext{active: true, messageID: 88, userID: 10001, groupID: 20002, isGroup: true}
|
||||||
|
|
||||||
|
ctx := toolCallContext("qq_get_history", map[string]interface{}{"group_id": int64(20003)})
|
||||||
|
if err := p.beforeToolcall(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ctx.Response == nil || !strings.Contains(*ctx.Response, "当前 QQ 会话") {
|
||||||
|
t.Fatalf("cross-group history not rejected: %#v", ctx.Response)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx = toolCallContext("qq_get_history", map[string]interface{}{"group_id": int64(20002)})
|
||||||
|
if err := p.beforeToolcall(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ctx.Response != nil {
|
||||||
|
t.Fatalf("current-group history rejected: %s", *ctx.Response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmatchedQQInputIsDowngraded(t *testing.T) {
|
||||||
|
p := newPermissionTestPlugin(t)
|
||||||
|
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
|
||||||
|
ctx := &sdk.StageContext{
|
||||||
|
RawMessage: "来自未知事件(message_id=404)",
|
||||||
|
Extra: map[string]interface{}{"input_source": "qq"},
|
||||||
|
}
|
||||||
|
if err := p.onInputAuthContext(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !p.auth.active || p.auth.owner || p.auth.userID != 0 {
|
||||||
|
t.Fatalf("unmatched input reused prior privilege: %+v", p.auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDuplicateQQOutputIsStopped(t *testing.T) {
|
||||||
|
p := newPermissionTestPlugin(t)
|
||||||
|
p.maxDuplicateSend = 1
|
||||||
|
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
|
||||||
|
args := map[string]interface{}{"payload": "same", "type": "text", "meta": `{"user_id":123}`}
|
||||||
|
|
||||||
|
ctx := toolCallContext("output_send__qq", args)
|
||||||
|
if err := p.beforeToolcall(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ctx.Response != nil {
|
||||||
|
t.Fatalf("first send rejected: %s", *ctx.Response)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx = toolCallContext("output_send__qq", args)
|
||||||
|
if err := p.beforeToolcall(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ctx.Response == nil || !strings.Contains(*ctx.Response, "循环保险") {
|
||||||
|
t.Fatalf("duplicate send not stopped: %#v", ctx.Response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupAndUserRouteAddsLeadingMention(t *testing.T) {
|
||||||
|
var path string
|
||||||
|
var request map[string]interface{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path = r.URL.Path
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
t.Errorf("decode request: %v", err)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"status":"ok","retcode":0,"data":{"message_id":1}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := newPermissionTestPlugin(t)
|
||||||
|
p.napcatURL = server.URL
|
||||||
|
p.httpClient = server.Client()
|
||||||
|
_, err := p.handleChannelOutput(map[string]interface{}{
|
||||||
|
"payload": "hello",
|
||||||
|
"type": "text",
|
||||||
|
"meta": `{"group_id":20002,"user_id":10001}`,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if path != "/send_group_msg" {
|
||||||
|
t.Fatalf("path=%q, want /send_group_msg", path)
|
||||||
|
}
|
||||||
|
segments, ok := request["message"].([]interface{})
|
||||||
|
if !ok || len(segments) < 2 {
|
||||||
|
t.Fatalf("message is not a segment array: %#v", request["message"])
|
||||||
|
}
|
||||||
|
mention, _ := segments[0].(map[string]interface{})
|
||||||
|
data, _ := mention["data"].(map[string]interface{})
|
||||||
|
if mention["type"] != "at" || data["qq"] != "10001" {
|
||||||
|
t.Fatalf("leading mention=%#v", mention)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回归:循环保险曾按“总数”拦截,导致参数不同且必需的调用被误杀。
|
||||||
|
// 现在只拦参数完全相同的重复调用。
|
||||||
|
func TestDistinctQQOutputsAreNotTreatedAsDuplicates(t *testing.T) {
|
||||||
|
p := newPermissionTestPlugin(t)
|
||||||
|
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
|
||||||
|
// maxDuplicateSend 默认 1:同一条消息重复才会被拦,不同消息必须全部放行。
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
ctx := toolCallContext("output_send__qq", map[string]interface{}{
|
||||||
|
"payload": fmt.Sprintf("message-%d", i),
|
||||||
|
"type": "text",
|
||||||
|
"meta": `{"user_id":123}`,
|
||||||
|
})
|
||||||
|
if err := p.beforeToolcall(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ctx.Response != nil {
|
||||||
|
t.Fatalf("distinct message %d was blocked: %s", i, *ctx.Response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDistinctNecessaryToolCallsAreNotBlocked(t *testing.T) {
|
||||||
|
p := newPermissionTestPlugin(t)
|
||||||
|
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
|
||||||
|
// 旧实现 maxQQToolCalls=32 会在第 33 个不同参数的必需调用处误拦。
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
ctx := toolCallContext("cmd_run", map[string]interface{}{"command": fmt.Sprintf("cmd-%d", i)})
|
||||||
|
if err := p.beforeToolcall(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ctx.Response != nil {
|
||||||
|
t.Fatalf("necessary tool call %d was blocked: %s", i, *ctx.Response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZeroLimitsMeanUnlimited(t *testing.T) {
|
||||||
|
p := newPermissionTestPlugin(t)
|
||||||
|
p.maxQQOutputCalls = 0
|
||||||
|
p.maxDuplicateSend = 0
|
||||||
|
p.maxQQToolCalls = 0
|
||||||
|
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
ctx := toolCallContext("output_send__qq", map[string]interface{}{
|
||||||
|
"payload": "same-content",
|
||||||
|
"type": "text",
|
||||||
|
"meta": `{"user_id":123}`,
|
||||||
|
})
|
||||||
|
if err := p.beforeToolcall(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ctx.Response != nil {
|
||||||
|
t.Fatalf("0 should mean unlimited, blocked at %d: %s", i, *ctx.Response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,7 +5,7 @@ rss plugin
|
|||||||
## Build
|
## Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
plugindev build
|
hmapdev build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|||||||
@ -300,7 +300,10 @@ func (p *Plugin) checkFeed(sub FeedSub) {
|
|||||||
lines = append(lines, line)
|
lines = append(lines, line)
|
||||||
}
|
}
|
||||||
|
|
||||||
p.sdk.InjectInterruptText("rss", "rss", strings.Join(lines, "\n"))
|
// 中断注入是「系统通知」,NoMemory 写明意图:这类提醒不参与记忆计算,
|
||||||
|
// 原文仍进上下文(模型当轮看得到)。
|
||||||
|
p.sdk.InjectInterruptTextOpts("rss", "rss", strings.Join(lines, "\n"),
|
||||||
|
sdk.InjectOptions{NoMemory: true})
|
||||||
p.saveData()
|
p.saveData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ weather plugin
|
|||||||
## Build
|
## Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
plugindev build
|
hmapdev build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|||||||
152
meta/meta.go
152
meta/meta.go
@ -1,12 +1,56 @@
|
|||||||
// Package meta 收集 HomeAgent SDK 的全部元数据。
|
// Package meta 收集 HomeAgent SDK 的全部元数据。
|
||||||
// 版本号应与核心 meta.Version 保持一致。
|
// 版本号应与核心 meta.Version 保持一致。
|
||||||
// ABI 版本与 Dispatch Method ID 应与核心仓 internal/meta/meta.go 保持一致。
|
|
||||||
package meta
|
package meta
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// Version 是 HomeAgent SDK 版本号。
|
// Version 是 HomeAgent SDK 版本号。
|
||||||
// 通过 `-ldflags="-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=vX.Y.Z"` 注入。
|
// 通过 `-ldflags="-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=vX.Y.Z"` 注入。
|
||||||
Version = "0.9.2"
|
//
|
||||||
|
// 版本号语义:**SDK 版本跟随核心的中版本,patch 位恒为 .0**。
|
||||||
|
// 整条核心 1.1.x 线(1.1.0、1.1.1、1.1.7…)共用 SDK 1.1.0;
|
||||||
|
// 只有核心进入 1.2.0 这种中版本跃迁时 SDK 才升到 1.2.0。
|
||||||
|
// 这样插件开发者只需关心「我在为哪个中版本写插件」,
|
||||||
|
// 不必跟着核心的每个 bugfix 换 SDK 依赖(见 核心仓 docs/git-branching.md §七)。
|
||||||
|
//
|
||||||
|
// 1.0.0:插件运行模型从 C ABI 动态库改为子进程 + 共享内存。
|
||||||
|
// 公开 SDK 接口零改动,但产物形态变了(plugin.so → plugin.bin)。
|
||||||
|
// 1.1.0:多模态贯通插件边界。**全部是新增,无签名变更**:
|
||||||
|
// - Triple.SentenceText / Triple.MediaDigests
|
||||||
|
// - Doc.MediaDigests / Doc.Attachments、MediaAttachment
|
||||||
|
// - TextEvent.Attachments
|
||||||
|
// - DocMemoryAPI.InsertWithMedia
|
||||||
|
// - IOInjector 的 InjectInputMedia / InjectInputMediaSync /
|
||||||
|
// InjectInterruptMedia;PluginSDK 补上缺失的 SetToolBlocks 包装
|
||||||
|
// 同版修掉两处并发竞态(sdk/stress_test.go 的 -race 实证,不是理论风险):
|
||||||
|
// PluginSDK 的 API 字段与 autoRestart 标志此前无锁,而写方
|
||||||
|
// (内核注入 API、插件 SetAutoRestart)与读方(插件后台 goroutine
|
||||||
|
// 注入、内核 registry 读 AutoRestart)天然跨 goroutine。
|
||||||
|
// 存量插件不需要改一行也不需要重编:新增方法由**插件调用、内核实现**,
|
||||||
|
// 不调就不受影响。想用新字段的插件重编即可。
|
||||||
|
//
|
||||||
|
// 1.2.0:注入行为的记忆/裁剪标志位。**全部是新增,无签名变更**:
|
||||||
|
// - InjectOptions{NoMemory, ContextPolicy}
|
||||||
|
// - IOInjector 的六个 *Opts 变体(排队/中断/同步/带媒体各一对)
|
||||||
|
// - ChannelDef.ContextPolicy(顺带给 ChannelDef 补上 JSON tag:
|
||||||
|
// 它要跨进程传给内核,而 Cleaner 是函数必须忽略;无 tag 时只能
|
||||||
|
// 手写字段白名单,新增字段会被静默丢掉)
|
||||||
|
// 语义:零值 InjectOptions 与旧的三参数方法完全等价(记入记忆 +
|
||||||
|
// 不裁剪),因此存量插件不需要改一行也不需要重编。
|
||||||
|
// 裁剪(ContextPolicy=prune)必须显式声明——它会归档丢弃低相关事件。
|
||||||
|
//
|
||||||
|
// ❗main 分支上此值是**下一个未发布中版本**;已发布的值看对应的
|
||||||
|
// release/vX.Y.x 分支与 tag(见 核心仓 docs/git-branching.md §2.1 与 §七.1)。
|
||||||
|
//
|
||||||
|
// 现为 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。
|
// Commit 是构建时的 Git commit hash。
|
||||||
Commit = "unknown"
|
Commit = "unknown"
|
||||||
@ -17,11 +61,23 @@ var (
|
|||||||
// SDKName 是 SDK 名称。
|
// SDKName 是 SDK 名称。
|
||||||
SDKName = "HomeAgent SDK"
|
SDKName = "HomeAgent SDK"
|
||||||
|
|
||||||
// CoreModule 是核心仓的 Go module path,供 plugindev 生成 go.mod 时使用。
|
// CoreModule 是核心仓的 Go module path,供 hmapdev 生成 go.mod 时使用。
|
||||||
CoreModule = "gitcode.com/JianFeeeee/HomeAgent"
|
CoreModule = "gitcode.com/JianFeeeee/HomeAgent"
|
||||||
|
|
||||||
// CoreVersion 是此 SDK 所兼容的最低核心版本。
|
// CoreVersion 是此 SDK 所兼容的最低核心版本。
|
||||||
CoreVersion = "0.9.2"
|
//
|
||||||
|
// 1.0.0 是硬下限而非建议值:0.9.x 内核只会 dlopen `.so`,
|
||||||
|
// 本版工具链产出的 `plugin.bin` 在旧内核上根本不会被识别。
|
||||||
|
//
|
||||||
|
// ⚠️ 1.1.0 新增的媒体接口需要核心 **1.1.1+**(更早的核心没有
|
||||||
|
// doc.insertWithMedia / io.injectMedia* 这些 RPC,调用会返回 unknown method)。
|
||||||
|
// 这里仍写 1.0.0,因为它是「SDK 能在其上运行」的下限;
|
||||||
|
// 媒体接口是可选能力,不用就不受影响。
|
||||||
|
//
|
||||||
|
// ⚠️ 1.2.0 新增的注入标志位同理需要核心 **1.2.0+**:内核在 1.2.0 之前会
|
||||||
|
// 忽略注入参数里的 no_memory/context_policy 字段(不会报错,但不生效)。
|
||||||
|
// 想用这些标志位的插件应当要求核心 1.2.0+;不用就不受影响。
|
||||||
|
CoreVersion = "1.0.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FullVersion 返回完整的版本字符串。
|
// FullVersion 返回完整的版本字符串。
|
||||||
@ -29,79 +85,15 @@ func FullVersion() string {
|
|||||||
return SDKName + " v" + Version + " (" + Commit + ")"
|
return SDKName + " v" + Version + " (" + Commit + ")"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- ABI 版本(与核心仓 internal/meta/meta.go 同步) ----
|
// ---- 协议版本 ----
|
||||||
// ABI 标识版本直接取内核版本号字符串(semver),与核心 Version 保持一致,不使用独立数字编码。
|
//
|
||||||
// 协商层(C 结构体 int version 字段)使用 CABINum:由版本字符串派生的整数(major*100 + minor)。
|
// 子进程 RPC 的协议版本是一个独立的小整数,与 SDK/内核语义版本解耦:
|
||||||
// 映射:v0.8.x → CABINum=800;v0.9.x → CABINum=900(invoke_stage 写回)。
|
// 语义版本变动频繁(修 bug、加字段),而 wire 协议只在**帧格式或握手语义**
|
||||||
// 小版本(patch)演进不影响 ABI,CABINum 不变。version_min 保证旧 ABI 插件仍可加载。
|
// 变化时才升。当前值见核心仓 internal/plugin/proc/protocol.go 的 ProtocolVersion。
|
||||||
|
//
|
||||||
var (
|
// C ABI 时代的 ABIVersion / CABINum / 51 个 Core<Method> 整数 ID 已随
|
||||||
// ABIVersion 是 ABI 标识版本(字符串 semver,与 SDK CoreVersion 对齐)。
|
// Part 6.2 删除 internal/plugin/cabi/ 一并退场:
|
||||||
ABIVersion = CoreVersion
|
// - 整数 method id 平移为 method 名字符串(proc/protocol.go 的 Method* 常量)
|
||||||
// ABIVersionMin 是兼容的最低 ABI 标识版本。
|
// - 版本协商改为握手帧里的 protocol 字段
|
||||||
ABIVersionMin = "0.8.0"
|
//
|
||||||
)
|
// 保留那些常量只会让人以为它们还在生效。
|
||||||
|
|
||||||
const (
|
|
||||||
// CABINum 是 C 层协商用的整数版本(major*100 + minor),随 ABIVersion 派生。
|
|
||||||
CABINum = 900
|
|
||||||
// CABINumMin 是 C 层兼容的最低整数版本。
|
|
||||||
// 旧工具链(v0.8 之前)写入的整数 version=1,无写回能力但与新内核结构兼容,
|
|
||||||
// 因此最小值保持 1 以兼容全部旧插件(新插件 900 匹配,旧插件 1/2 通过);
|
|
||||||
// 仅当未来内核 ABI 破坏兼容时才提高该值。
|
|
||||||
CABINumMin = 1
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---- Dispatch Method IDs(与核心仓 internal/meta/meta.go 同步) ----
|
|
||||||
const (
|
|
||||||
CoreRegisterTool = 1
|
|
||||||
CoreRegisterStage = 2
|
|
||||||
CoreRegisterOutputCh = 3
|
|
||||||
CoreRegisterPluginAPI = 4
|
|
||||||
CoreInjectText = 5
|
|
||||||
CoreInjectInterruptText = 6
|
|
||||||
CoreInjectTextNoMemory = 7
|
|
||||||
CoreSetAutoRestart = 8
|
|
||||||
CoreMemoryRecall = 9
|
|
||||||
CoreMemoryCommit = 10
|
|
||||||
CoreMemoryIntrospect = 11
|
|
||||||
CoreMemoryMerge = 12
|
|
||||||
CoreMemoryPurge = 13
|
|
||||||
CoreDocQuery = 14
|
|
||||||
CoreKnowledgeSearch = 15
|
|
||||||
CoreSettingsGet = 16
|
|
||||||
CoreSettingsSet = 17
|
|
||||||
CoreSettingsRegisterDef = 18
|
|
||||||
CoreLLMListSources = 19
|
|
||||||
CoreLLMSetSource = 20
|
|
||||||
CoreSocialGetPerson = 21
|
|
||||||
CoreSocialGetNetwork = 22
|
|
||||||
CoreSubscribe = 23
|
|
||||||
CoreUnsubscribe = 24
|
|
||||||
CoreFreeString = 25
|
|
||||||
CoreSettingsGetCore = 26
|
|
||||||
CoreSettingsSetCore = 27
|
|
||||||
CoreSettingsListCore = 28
|
|
||||||
CoreSettingsGetPlugin = 29
|
|
||||||
CoreSettingsSetPlugin = 30
|
|
||||||
CoreSettingsListPlugin = 31
|
|
||||||
CoreDocInsert = 32
|
|
||||||
CoreDocRemove = 33
|
|
||||||
CoreDocStats = 34
|
|
||||||
CoreKnowledgeAdd = 35
|
|
||||||
CoreKnowledgeList = 36
|
|
||||||
CoreLLMCurrentSource = 37
|
|
||||||
CoreSocialGetTrait = 38
|
|
||||||
CoreSocialGetRelations = 39
|
|
||||||
CoreSocialListPersons = 40
|
|
||||||
CoreTextMemoryAppend = 41
|
|
||||||
CoreSettingsList = 42
|
|
||||||
CoreSettingsDefs = 43
|
|
||||||
CoreSettingsDump = 44
|
|
||||||
CoreSettingsPlugins = 45
|
|
||||||
CoreRegisterInputCh = 46
|
|
||||||
CoreInjectInputSync = 47
|
|
||||||
CorePluginReloadOne = 48
|
|
||||||
CorePluginListLoaded = 49
|
|
||||||
CorePluginIsDisabled = 50
|
|
||||||
)
|
|
||||||
|
|||||||
150
package/build-examples.sh
Normal file
150
package/build-examples.sh
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 给 SDK 发版打包**示例插件**的 .hmap 产物。
|
||||||
|
#
|
||||||
|
# 为什么要在 SDK 仓库里发示例插件的 hmap:
|
||||||
|
# 插件二进制与内核是**协议绑定**的(internal/plugin/proc/protocol.go 的
|
||||||
|
# ProtocolVersion + 统一共享内存区魔数)。SDK 升版往往同时意味着协议变化,
|
||||||
|
# 而示例插件(qq/memo/browser/…)是使用者最常直接安装的东西。
|
||||||
|
# 如果 SDK 只发工具链不发示例产物,使用者要么自己重编、要么用到与本版 SDK
|
||||||
|
# 不匹配的旧产物——后者的表现是握手失败(协议/魔数不匹配),而且看起来像
|
||||||
|
# 「插件坏了」而不是「版本不配套」。
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# package/build-examples.sh [TARGET] [OUT_DIR]
|
||||||
|
# TARGET native(默认) | linux/amd64 | linux/arm64 | darwin/amd64 | darwin/arm64 | windows/amd64 | all
|
||||||
|
# OUT_DIR 产物目录(默认 build/examples)
|
||||||
|
#
|
||||||
|
# 产物:
|
||||||
|
# <OUT_DIR>/<name>_<goos>_<goarch>.hmap 每个示例插件一份
|
||||||
|
# <OUT_DIR>/SHA256SUMS 全部产物齐全**之后**才计算
|
||||||
|
# <OUT_DIR>/MANIFEST.txt 版本、协议版本、产自哪个 commit
|
||||||
|
#
|
||||||
|
# 纪律(与本项目其它构建脚本一致):
|
||||||
|
# 1. 判成功看**产物是否存在**,不看退出码——hmapdev 对部分错误只打印不退出。
|
||||||
|
# 2. SHA256SUMS 必须在全部产物生成完毕后一次算完,边打边算会漏掉后生成的包。
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SDK_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
TARGET="${1:-native}"
|
||||||
|
OUT_DIR="${2:-$SDK_ROOT/build/examples}"
|
||||||
|
GO="${GO:-$(command -v go 2>/dev/null || echo go)}"
|
||||||
|
|
||||||
|
case "$TARGET" in
|
||||||
|
native) GOOS=""; GOARCH="" ;;
|
||||||
|
linux/amd64) GOOS=linux; GOARCH=amd64 ;;
|
||||||
|
linux/arm64) GOOS=linux; GOARCH=arm64 ;;
|
||||||
|
darwin/amd64) GOOS=darwin; GOARCH=amd64 ;;
|
||||||
|
darwin/arm64) GOOS=darwin; GOARCH=arm64 ;;
|
||||||
|
windows/amd64)
|
||||||
|
# 明确拒绝,而不是让调用方拿到一句深层 Go 编译错误。
|
||||||
|
# 协议 2 的统一共享内存区只移植到了 Unix:内核 internal/plugin/proc/
|
||||||
|
# shmpass_windows.go 仍是旧的 SHM_STAGE/SHM_EVTRING 两段布局,
|
||||||
|
# 插件模板 proc_shm_windows.go 也缺 attachUnifiedShm。
|
||||||
|
echo "windows 目标暂不支持:协议 2 的统一共享内存区未移植到 Windows(内核与插件模板均缺实现)。" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
all)
|
||||||
|
echo "本脚本一次只构建一个平台;请由 package/build.sh 传入具体目标。" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown target: $TARGET" >&2
|
||||||
|
echo "Usage: $0 [native|linux/amd64|linux/arm64|darwin/amd64|darwin/arm64|windows/amd64|all] [OUT_DIR]" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
export CGO_ENABLED=0
|
||||||
|
|
||||||
|
# 按平台逐个构建,**不用** bundle 模式:
|
||||||
|
# - bundle 会连 windows 一起编,而协议 2 的统一共享区尚未移植到 Windows
|
||||||
|
# (内核 shmpass_windows.go 仍是旧的两段布局),必然失败;
|
||||||
|
# - 逐平台构建每个目标都产出一份 .hmap,正是发版要附的产物。
|
||||||
|
# 平台名解析成本脚本后面用(校验和与 MANIFEST 都要写清楚是哪个平台)。
|
||||||
|
if [ -z "${GOOS:-}" ]; then
|
||||||
|
GOOS="$(go env GOOS)"; GOARCH="$(go env GOARCH)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1) 先保证工具链可用:示例必须用**本仓当前源码**构建,否则产物协议与这一版 SDK 不符。
|
||||||
|
# 允许外部指定(发版脚本会在跨平台构建后把刚产出的工具链路径传进来)。
|
||||||
|
# 工具链二进制名由 plugindev 改为 hmapdev;旧变量名 PLUGINDEV 仍兼容。
|
||||||
|
HMAPDEV="${HMAPDEV:-${PLUGINDEV:-$SDK_ROOT/build/hmapdev}}"
|
||||||
|
if [ ! -x "$PLUGINDEV" ]; then
|
||||||
|
echo "[examples] 先构建 hmapdev ..."
|
||||||
|
( cd "$SDK_ROOT/tools/hmapdev" && "$GO" build -o "$HMAPDEV" . ) || {
|
||||||
|
echo "[examples] hmapdev 构建失败,无法继续" >&2; exit 1; }
|
||||||
|
fi
|
||||||
|
if [ ! -x "$PLUGINDEV" ]; then
|
||||||
|
echo "[examples] hmapdev 不存在或不可执行:$HMAPDEV" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== 协议 ==="
|
||||||
|
echo " ProtocolVersion = $(grep -m1 '^const ProtocolVersion' "$SDK_ROOT/../internal/plugin/proc/protocol.go" 2>/dev/null | grep -oE '[0-9]+' || echo '?(本仓非内核仓,跳过)')"
|
||||||
|
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
# 清掉上一次的校验和:残留的 SHA256SUMS 会掩盖本次缺产物。
|
||||||
|
rm -f "$OUT_DIR"/SHA256SUMS "$OUT_DIR"/MANIFEST.txt
|
||||||
|
|
||||||
|
ok=0
|
||||||
|
fail=0
|
||||||
|
failed_names=""
|
||||||
|
|
||||||
|
for dir in "$SDK_ROOT"/example/*/; do
|
||||||
|
[ -f "$dir/plugin.go" ] || continue
|
||||||
|
name="$(basename "$dir")"
|
||||||
|
|
||||||
|
# 清掉旧产物:残留会让人(和本脚本)误判成功。
|
||||||
|
rm -rf "$dir/build" "$dir/dist"
|
||||||
|
|
||||||
|
out=$( cd "$dir" && "$PLUGINDEV" build --no-bundle --target "$GOOS/$GOARCH" 2>&1 )
|
||||||
|
rc=$?
|
||||||
|
|
||||||
|
# 判据是**退出码 + 产物存在**,两者都要。
|
||||||
|
# 只看退出码:hmapdev 曾经出错也退 0(已修,但脚本不该依赖它「现在」是对的)。
|
||||||
|
# 只看产物:部分平台失败时会留下上一次的产物,看起来像成功。
|
||||||
|
hmap="$(ls "$dir"/dist/*.hmap 2>/dev/null | head -1)"
|
||||||
|
if [ $rc -eq 0 ] && [ -n "$hmap" ]; then
|
||||||
|
# 保留插件自己声明的产物名(它用的是 plg.json 的 name_en,是插件的身份),
|
||||||
|
# 只在前面加平台前缀避免多平台互相覆盖。
|
||||||
|
dest="$OUT_DIR/${GOOS}_${GOARCH}_$(basename "$hmap")"
|
||||||
|
cp "$hmap" "$dest"
|
||||||
|
printf "✓ %-14s → %s (%s)\n" "$name" "$(basename "$dest")" "$(du -h "$dest" | cut -f1)"
|
||||||
|
ok=$((ok + 1))
|
||||||
|
else
|
||||||
|
printf "✗ %-14s 构建失败 (rc=%d)\n" "$name" "$rc"
|
||||||
|
echo "$out" | tail -6 | sed 's/^/ /'
|
||||||
|
fail=$((fail + 1))
|
||||||
|
failed_names="$failed_names $name"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "示例产物: 成功 $ok / 失败 $fail"
|
||||||
|
[ -n "$failed_names" ] && echo "失败:$failed_names"
|
||||||
|
|
||||||
|
# 有失败就不算发版闭环:宁可整个中断,也不要发出「少几个插件」的包。
|
||||||
|
if [ $fail -ne 0 ]; then
|
||||||
|
echo "[examples] 有示例构建失败,不生成 SHA256SUMS" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2) 全部产物齐了才算校验和。
|
||||||
|
( cd "$OUT_DIR" && sha256sum ./*.hmap > SHA256SUMS )
|
||||||
|
|
||||||
|
VERSION="${VERSION:-$(git -C "$SDK_ROOT" describe --tags --dirty 2>/dev/null || echo unknown)}"
|
||||||
|
COMMIT="${COMMIT:-$(git -C "$SDK_ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)}"
|
||||||
|
{
|
||||||
|
echo "sdk_version: $VERSION"
|
||||||
|
echo "sdk_commit: $COMMIT"
|
||||||
|
echo "target: $GOOS/$GOARCH"
|
||||||
|
echo "plugins: $ok"
|
||||||
|
echo "built_at: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
echo
|
||||||
|
echo "这些 .hmap 与本版 SDK 的插件协议绑定,必须与同版本内核配套安装。"
|
||||||
|
echo "校验:sha256sum -c SHA256SUMS"
|
||||||
|
} > "$OUT_DIR/MANIFEST.txt"
|
||||||
|
|
||||||
|
echo "[examples] 产物: $OUT_DIR"
|
||||||
|
echo "[examples] 清单: $OUT_DIR/MANIFEST.txt"
|
||||||
|
echo "[examples] 校验: $OUT_DIR/SHA256SUMS"
|
||||||
@ -5,6 +5,13 @@ PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|||||||
BUILD_DIR="${PROJECT_ROOT}/build"
|
BUILD_DIR="${PROJECT_ROOT}/build"
|
||||||
VERSION="${VERSION:-$(git -C "$PROJECT_ROOT" describe --tags --dirty 2>/dev/null || echo "0.7.1")}"
|
VERSION="${VERSION:-$(git -C "$PROJECT_ROOT" describe --tags --dirty 2>/dev/null || echo "0.7.1")}"
|
||||||
GO="${GO:-$(command -v go 2>/dev/null || echo "/home/jianf/go1.26.5/go/bin/go")}"
|
GO="${GO:-$(command -v go 2>/dev/null || echo "/home/jianf/go1.26.5/go/bin/go")}"
|
||||||
|
# 宿主平台必须在**本脚本 export GOOS/GOARCH 之前**取定。
|
||||||
|
# 否则 `go env GOOS` 会返回被 export 的目标平台(此前 `build.sh all all`
|
||||||
|
# 就是因此拿 darwin 二进制在 linux 上跑,报 cannot execute binary file)。
|
||||||
|
NATIVE_GOOS="$(env -u GOOS -u GOARCH "$GO" env GOOS 2>/dev/null || uname -s | tr 'A-Z' 'a-z')"
|
||||||
|
NATIVE_GOARCH="$(env -u GOOS -u GOARCH "$GO" env GOARCH 2>/dev/null || uname -m)"
|
||||||
|
case "$NATIVE_GOARCH" in x86_64|amd64) NATIVE_GOARCH="amd64" ;; aarch64|arm64) NATIVE_GOARCH="arm64" ;; esac
|
||||||
|
case "$NATIVE_GOOS" in darwin|linux|windows) ;; *) NATIVE_GOOS="linux" ;; esac
|
||||||
GOCACHE="${GOCACHE:-}"
|
GOCACHE="${GOCACHE:-}"
|
||||||
GOPATH="${GOPATH:-}"
|
GOPATH="${GOPATH:-}"
|
||||||
|
|
||||||
@ -28,7 +35,7 @@ case "$TARGET" in
|
|||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown target: $TARGET"
|
echo "Unknown target: $TARGET"
|
||||||
echo "Usage: $0 [native|linux/amd64|linux/arm64|darwin/amd64|darwin/arm64|windows/amd64|all] [all|plugindev]"
|
echo "Usage: $0 [native|linux/amd64|linux/arm64|darwin/amd64|darwin/arm64|windows/amd64|all] [all|hmapdev|examples]"
|
||||||
exit 1
|
exit 1
|
||||||
esac
|
esac
|
||||||
|
|
||||||
@ -42,12 +49,12 @@ export CGO_ENABLED=0
|
|||||||
|
|
||||||
mkdir -p "$BUILD_DIR"
|
mkdir -p "$BUILD_DIR"
|
||||||
|
|
||||||
build_plugindev() {
|
build_hmapdev() {
|
||||||
local src="tools/plugindev"
|
local src="tools/hmapdev"
|
||||||
local out="$BUILD_DIR/plugindev${SUFFIX:+_$SUFFIX}"
|
local out="$BUILD_DIR/hmapdev${SUFFIX:+_$SUFFIX}"
|
||||||
if [ "$GOOS" = "windows" ]; then out="${out}.exe"; fi
|
if [ "$GOOS" = "windows" ]; then out="${out}.exe"; fi
|
||||||
|
|
||||||
echo "[BUILD] plugindev ${GOOS:-linux}/${GOARCH:-amd64} → $out"
|
echo "[BUILD] hmapdev ${GOOS:-linux}/${GOARCH:-amd64} → $out"
|
||||||
cd "$PROJECT_ROOT/$src"
|
cd "$PROJECT_ROOT/$src"
|
||||||
"$GO" build -trimpath -ldflags "-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=${VERSION}" \
|
"$GO" build -trimpath -ldflags "-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=${VERSION}" \
|
||||||
-o "$out" .
|
-o "$out" .
|
||||||
@ -55,9 +62,47 @@ build_plugindev() {
|
|||||||
cd "$PROJECT_ROOT"
|
cd "$PROJECT_ROOT"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 示例插件产物随 SDK 一起发。
|
||||||
|
#
|
||||||
|
# 为什么必须发:插件二进制与内核是**协议绑定**的(ProtocolVersion + 统一共享
|
||||||
|
# 内存区魔数)。SDK 升版常伴随协议变化,只发工具链不发示例产物,使用者很可能
|
||||||
|
# 拿旧产物去装,表现是握手失败(魔数不匹配)——看起来像「插件坏了」而不是
|
||||||
|
# 「版本不配套」。
|
||||||
|
#
|
||||||
|
# 用**宿主可执行**的那把工具链(而非 PATH 里的),保证产物与本次发版同源。
|
||||||
|
#
|
||||||
|
# 为什么不能用目标平台的那把:示例的跨平台构建是由 hmapdev 的 `--target GOOS/GOARCH`
|
||||||
|
# 完成的,被执行的进程本身必須能在当前机器上跑。拿目标平台的二进制去跑只会得到
|
||||||
|
# “cannot execute binary file: Exec format error”(`build.sh all all` 在 darwin 处断过)。
|
||||||
|
build_examples() {
|
||||||
|
local dev
|
||||||
|
dev="$BUILD_DIR/hmapdev_${NATIVE_GOOS}_${NATIVE_GOARCH}"
|
||||||
|
[ "$NATIVE_GOOS" = "windows" ] && dev="${dev}.exe"
|
||||||
|
# 宿主工具链缺失时先补建(`all` 的第一个目标可能不是宿主平台)。
|
||||||
|
if [ ! -x "$dev" ]; then
|
||||||
|
echo "[BUILD] 先补建宿主工具链 ${NATIVE_GOOS}/${NATIVE_GOARCH}(示例的跨平台由 --target 完成)"
|
||||||
|
( unset GOOS GOARCH; bash "$0" "${NATIVE_GOOS}/${NATIVE_GOARCH}" hmapdev ) || return 1
|
||||||
|
fi
|
||||||
|
if [ ! -x "$dev" ]; then
|
||||||
|
echo "[BUILD] 无法构建示例:缺少宿主可执行的工具链 $dev" >&2
|
||||||
|
echo " 先跑: $0 ${NATIVE_GOOS}/${NATIVE_GOARCH} hmapdev" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "[BUILD] example plugins ${GOOS:-linux}/${GOARCH:-amd64} → $BUILD_DIR/examples(用 ${NATIVE_GOOS}/${NATIVE_GOARCH} 的工具链交叉构建)"
|
||||||
|
PLUGINDEV="$dev" VERSION="$VERSION" bash "$PROJECT_ROOT/package/build-examples.sh" "$TARGET" "$BUILD_DIR/examples"
|
||||||
|
echo " OK"
|
||||||
|
}
|
||||||
|
|
||||||
case "$COMPONENT" in
|
case "$COMPONENT" in
|
||||||
all|plugindev) build_plugindev ;;
|
all)
|
||||||
|
# 工具链必须先建完:示例用它来构建(同源保证协议一致)。
|
||||||
|
build_hmapdev
|
||||||
|
build_examples
|
||||||
|
;;
|
||||||
|
hmapdev) build_hmapdev ;;
|
||||||
|
examples) build_examples ;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown component: $COMPONENT"
|
echo "Unknown component: $COMPONENT"
|
||||||
exit 1
|
exit 1
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@ -48,7 +48,7 @@ Function pageConfirm
|
|||||||
${EndIf}
|
${EndIf}
|
||||||
${NSD_CreateLabel} 0 5u 100% 12u "将安装以下组件:"
|
${NSD_CreateLabel} 0 5u 100% 12u "将安装以下组件:"
|
||||||
Pop $0
|
Pop $0
|
||||||
${NSD_CreateLabel} 15u 20u 100% 12u "• plugindev.exe — 插件开发工具"
|
${NSD_CreateLabel} 15u 20u 100% 12u "• hmapdev.exe — 插件开发工具"
|
||||||
Pop $0
|
Pop $0
|
||||||
${NSD_CreateLabel} 15u 35u 100% 12u "• SDK ${SDK_VERSION} — 将从远程仓库自动下载"
|
${NSD_CreateLabel} 15u 35u 100% 12u "• SDK ${SDK_VERSION} — 将从远程仓库自动下载"
|
||||||
Pop $0
|
Pop $0
|
||||||
@ -64,11 +64,11 @@ Section "Install" SEC_INSTALL
|
|||||||
SetOutPath "$INSTDIR"
|
SetOutPath "$INSTDIR"
|
||||||
|
|
||||||
DetailPrint "复制工具链文件..."
|
DetailPrint "复制工具链文件..."
|
||||||
File "plugindev.exe"
|
File "hmapdev.exe"
|
||||||
|
|
||||||
DetailPrint "创建快捷方式..."
|
DetailPrint "创建快捷方式..."
|
||||||
CreateDirectory "$SMPROGRAMS\${PRODUCT_NAME}"
|
CreateDirectory "$SMPROGRAMS\${PRODUCT_NAME}"
|
||||||
CreateShortCut "$SMPROGRAMS\${PRODUCT_NAME}\plugindev.lnk" "$INSTDIR\plugindev.exe" "" "$INSTDIR\plugindev.exe" 0
|
CreateShortCut "$SMPROGRAMS\${PRODUCT_NAME}\hmapdev.lnk" "$INSTDIR\hmapdev.exe" "" "$INSTDIR\hmapdev.exe" 0
|
||||||
|
|
||||||
DetailPrint "配置环境变量..."
|
DetailPrint "配置环境变量..."
|
||||||
; Add to system PATH
|
; Add to system PATH
|
||||||
@ -93,23 +93,23 @@ Section "Install" SEC_INSTALL
|
|||||||
DetailPrint "Git 已安装: $1"
|
DetailPrint "Git 已安装: $1"
|
||||||
${Else}
|
${Else}
|
||||||
DetailPrint "未检测到 Git,将跳过 SDK 自动下载"
|
DetailPrint "未检测到 Git,将跳过 SDK 自动下载"
|
||||||
DetailPrint "安装完成后请手动运行: plugindev sdk install ${SDK_VERSION}"
|
DetailPrint "安装完成后请手动运行: hmapdev sdk install ${SDK_VERSION}"
|
||||||
${EndIf}
|
${EndIf}
|
||||||
|
|
||||||
${If} $hasGit == "1"
|
${If} $hasGit == "1"
|
||||||
DetailPrint "正在下载 SDK ${SDK_VERSION}..."
|
DetailPrint "正在下载 SDK ${SDK_VERSION}..."
|
||||||
nsExec::ExecToStack '"$INSTDIR\plugindev.exe" sdk install ${SDK_VERSION}'
|
nsExec::ExecToStack '"$INSTDIR\hmapdev.exe" sdk install ${SDK_VERSION}'
|
||||||
Pop $0
|
Pop $0
|
||||||
Pop $1
|
Pop $1
|
||||||
${If} $0 == 0
|
${If} $0 == 0
|
||||||
StrCpy $sdkInstallOk "1"
|
StrCpy $sdkInstallOk "1"
|
||||||
DetailPrint "SDK ${SDK_VERSION} 下载完成"
|
DetailPrint "SDK ${SDK_VERSION} 下载完成"
|
||||||
DetailPrint "正在激活 SDK ${SDK_VERSION}..."
|
DetailPrint "正在激活 SDK ${SDK_VERSION}..."
|
||||||
nsExec::Exec '"$INSTDIR\plugindev.exe" sdk use ${SDK_VERSION}'
|
nsExec::Exec '"$INSTDIR\hmapdev.exe" sdk use ${SDK_VERSION}'
|
||||||
Pop $0
|
Pop $0
|
||||||
${Else}
|
${Else}
|
||||||
DetailPrint "SDK 下载失败 (错误码: $0)"
|
DetailPrint "SDK 下载失败 (错误码: $0)"
|
||||||
DetailPrint "请手动运行: plugindev sdk install ${SDK_VERSION}"
|
DetailPrint "请手动运行: hmapdev sdk install ${SDK_VERSION}"
|
||||||
${EndIf}
|
${EndIf}
|
||||||
${EndIf}
|
${EndIf}
|
||||||
|
|
||||||
@ -126,10 +126,10 @@ SectionEnd
|
|||||||
|
|
||||||
Section "Uninstall"
|
Section "Uninstall"
|
||||||
Delete "$INSTDIR\Uninstall.exe"
|
Delete "$INSTDIR\Uninstall.exe"
|
||||||
Delete "$INSTDIR\plugindev.exe"
|
Delete "$INSTDIR\hmapdev.exe"
|
||||||
RMDir /r "$INSTDIR\sdk"
|
RMDir /r "$INSTDIR\sdk"
|
||||||
RMDir "$INSTDIR"
|
RMDir "$INSTDIR"
|
||||||
Delete "$SMPROGRAMS\${PRODUCT_NAME}\plugindev.lnk"
|
Delete "$SMPROGRAMS\${PRODUCT_NAME}\hmapdev.lnk"
|
||||||
RMDir "$SMPROGRAMS\${PRODUCT_NAME}"
|
RMDir "$SMPROGRAMS\${PRODUCT_NAME}"
|
||||||
DeleteRegValue HKLM "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" "HOMEAGENT_SDK_DIR"
|
DeleteRegValue HKLM "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" "HOMEAGENT_SDK_DIR"
|
||||||
DeleteRegKey HKLM "Software\Microsoft\CurrentVersion\Uninstall\${PRODUCT_NAME}"
|
DeleteRegKey HKLM "Software\Microsoft\CurrentVersion\Uninstall\${PRODUCT_NAME}"
|
||||||
|
|||||||
@ -25,13 +25,18 @@ type Relation struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Triple represents a subject-relation-object triple for the knowledge graph.
|
// Triple represents a subject-relation-object triple for the knowledge graph.
|
||||||
|
//
|
||||||
|
// SentenceText 是这条三元组的原句,会写进 sentences 表;媒体引用挂在句子上,
|
||||||
|
// 所以 MediaDigests 非空时内核会保证句子存在(不给就自动合成一句)。
|
||||||
type Triple struct {
|
type Triple struct {
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
Relation string `json:"relation"`
|
Relation string `json:"relation"`
|
||||||
Object string `json:"object"`
|
Object string `json:"object"`
|
||||||
Confidence float64 `json:"confidence,omitempty"`
|
Confidence float64 `json:"confidence,omitempty"`
|
||||||
SubjectType string `json:"subject_type,omitempty"`
|
SubjectType string `json:"subject_type,omitempty"`
|
||||||
ObjectType string `json:"object_type,omitempty"`
|
ObjectType string `json:"object_type,omitempty"`
|
||||||
|
SentenceText string `json:"sentence_text,omitempty"`
|
||||||
|
MediaDigests []string `json:"media_digests,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TextMemoryAPI provides access to chronological text event storage.
|
// TextMemoryAPI provides access to chronological text event storage.
|
||||||
@ -40,27 +45,54 @@ type TextMemoryAPI interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TextEvent represents a single text memory event.
|
// TextEvent represents a single text memory event.
|
||||||
|
// MediaAttachment 描述一份与记忆关联的媒体。
|
||||||
|
//
|
||||||
|
// 两个方向共用一个类型:
|
||||||
|
// - 写入(InsertWithMedia):给 Data + MIME 就是新内容;只给 Digest 则是引用已有内容。
|
||||||
|
// - 读出(Query):内核只填 Digest/MIME,**不回 Data**——
|
||||||
|
// 一次检索可能命中几十张图,把字节全塞回插件会把 ABI 消息撑爆。
|
||||||
|
// 需要字节时拿 Digest 单独取。
|
||||||
|
//
|
||||||
|
// 刻意没有 Description 字段:媒体不作为文本被索引,也不带任何生成的描述。
|
||||||
|
// 它只按自己的原生向量被检索与召回;附加文字请写在文档 / 三元组的文本里。
|
||||||
|
type MediaAttachment struct {
|
||||||
|
Digest string `json:"digest,omitempty"`
|
||||||
|
MIME string `json:"mime,omitempty"`
|
||||||
|
Data []byte `json:"data,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type TextEvent struct {
|
type TextEvent struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Timestamp int64 `json:"timestamp"`
|
Timestamp int64 `json:"timestamp"`
|
||||||
Channel string `json:"channel,omitempty"`
|
Channel string `json:"channel,omitempty"`
|
||||||
|
Attachments []MediaAttachment `json:"attachments,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DocMemoryAPI provides access to the document vector store.
|
// DocMemoryAPI provides access to the document vector store.
|
||||||
type DocMemoryAPI interface {
|
type DocMemoryAPI interface {
|
||||||
Query(text string, topK int) []*Doc
|
Query(text string, topK int) []*Doc
|
||||||
Insert(doc *Doc) error
|
Insert(doc *Doc) error
|
||||||
|
// InsertWithMedia 写入文档并关联媒体。attachments 里带 Data 的会落进
|
||||||
|
// 内容寻址存储(相同字节只存一份),只带 Digest 的直接引用已有内容。
|
||||||
|
// 媒体成为文档直接持有的一等记忆块:文档向量会融合它们的原生向量,
|
||||||
|
// 因此图片按自己的向量被召回,不依赖任何生成的描述文本。
|
||||||
|
InsertWithMedia(doc *Doc, attachments []MediaAttachment) error
|
||||||
Remove(id string)
|
Remove(id string)
|
||||||
Stats() map[string]interface{}
|
Stats() map[string]interface{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Doc represents a document in the document store.
|
// Doc represents a document in the document store.
|
||||||
|
//
|
||||||
|
// MediaDigests / Attachments 在 Query 返回时由内核填充(仅元数据,不带字节)。
|
||||||
type Doc struct {
|
type Doc struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Score float64 `json:"score,omitempty"`
|
Score float64 `json:"score,omitempty"`
|
||||||
|
MediaDigests []string `json:"media_digests,omitempty"`
|
||||||
|
Attachments []MediaAttachment `json:"attachments,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SocialAPI provides read-only access to the social graph (person profiles and relationships).
|
// SocialAPI provides read-only access to the social graph (person profiles and relationships).
|
||||||
@ -75,9 +107,9 @@ type SocialAPI interface {
|
|||||||
|
|
||||||
// PersonProfile represents a person's complete profile (traits + social relations).
|
// PersonProfile represents a person's complete profile (traits + social relations).
|
||||||
type PersonProfile struct {
|
type PersonProfile struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Traits map[string]string `json:"traits,omitempty"`
|
Traits map[string]string `json:"traits,omitempty"`
|
||||||
Relations []SocialRelation `json:"relations,omitempty"`
|
Relations []SocialRelation `json:"relations,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SocialRelation represents a social relationship between two persons.
|
// SocialRelation represents a social relationship between two persons.
|
||||||
|
|||||||
378
sdk/plugin.go
378
sdk/plugin.go
@ -35,12 +35,63 @@ const (
|
|||||||
StageAfterOutput Stage = "after_output"
|
StageAfterOutput Stage = "after_output"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 上下文策略:决定一次工具调用/输入/注入是否依据其内容裁剪上下文。
|
||||||
|
//
|
||||||
|
// 默认(空串或 ContextPolicyNone)**不裁剪**:裁剪会归档丢弃低相关事件,
|
||||||
|
// 必须由工具/通道/注入点显式声明才发生——否则一个只想往上下文里塞内容的
|
||||||
|
// 插件会在背后把别人的内容挤掉,且看不出是谁干的。
|
||||||
|
const (
|
||||||
|
ContextPolicyNone = "none"
|
||||||
|
ContextPolicyPrune = "prune"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidContextPolicy 校验策略取值;空串等价于 ContextPolicyNone。
|
||||||
|
func ValidContextPolicy(policy string) bool {
|
||||||
|
switch policy {
|
||||||
|
case "", ContextPolicyNone, ContextPolicyPrune:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// InjectOptions 声明一次注入行为在记忆层与上下文层的表现。
|
||||||
|
//
|
||||||
|
// 零值 = 记入记忆 + 不裁剪上下文,与历史行为(三参数注入方法)完全一致,
|
||||||
|
// 因此调用方只有在确实需要改变行为时才需要填它。
|
||||||
|
//
|
||||||
|
// 为什么注入也要这两个标志:注入的内容来源千差万别——轮询到的频道消息
|
||||||
|
// 属于真实对话(该记),而“任务还在跑”“连接已重连”这类提醒不该污染记忆,
|
||||||
|
// 也不该把上下文按它的内容裁一遍。按调用点声明比按通道一刀切准确。
|
||||||
|
//
|
||||||
|
// NoMemory: 此次注入不参与记忆计算(向量化/关键词提取/蒸馏),原文仍留在上下文
|
||||||
|
// ContextPolicy: 此次注入后是否依据(清洗后的)内容裁剪上下文;默认不裁剪。
|
||||||
|
//
|
||||||
|
// 中断注入也允许声明 prune——它同样会携带内容进入上下文。
|
||||||
|
//
|
||||||
|
// CleanerName: 此次注入的内容用哪个**已注册的通道 cleaner** 清洗。
|
||||||
|
//
|
||||||
|
// 空串 = 按注入的 source 查通道定义(既有行为)。
|
||||||
|
// 为什么要能显式指定:注入的 source 未必是注册过的输入通道名,
|
||||||
|
// 而注入内容往往带 ANSI/JSON 包装,需要清洗后才是有效内容;
|
||||||
|
// 不指定就只能退到「按 source 查不到就不清洗」。
|
||||||
|
type InjectOptions struct {
|
||||||
|
NoMemory bool
|
||||||
|
ContextPolicy string
|
||||||
|
CleanerName string
|
||||||
|
}
|
||||||
|
|
||||||
// ChannelDef 描述通道在记忆计算层的行为,与 ToolDef.NoMemory/Cleaner 语义一致。
|
// ChannelDef 描述通道在记忆计算层的行为,与 ToolDef.NoMemory/Cleaner 语义一致。
|
||||||
// NoMemory: 此通道输入/输出不参与记忆计算(向量化/关键词提取/蒸馏),但原文保留在上下文中
|
// NoMemory: 此通道输入/输出不参与记忆计算(向量化/关键词提取/蒸馏),但原文保留在上下文中
|
||||||
// Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用
|
// Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用
|
||||||
|
// ContextPolicy: 此通道的输入到达后是否据此裁剪上下文,默认 none(不裁剪)
|
||||||
|
//
|
||||||
|
// JSON tag 是必需的:通道定义要跨进程传给内核,而 Cleaner 是函数(必须忽略)。
|
||||||
|
// 没有 tag 时既无法整体 marshal(func 不支持),又会诱使调用方手写字段白名单——
|
||||||
|
// 那样新增字段会被静默丢掉。
|
||||||
type ChannelDef struct {
|
type ChannelDef struct {
|
||||||
NoMemory bool
|
NoMemory bool `json:"no_memory,omitempty"`
|
||||||
Cleaner func(string) string
|
Cleaner func(string) string `json:"-"`
|
||||||
|
ContextPolicy string `json:"context_policy,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StageContext provides context for stage handlers.
|
// StageContext provides context for stage handlers.
|
||||||
@ -61,14 +112,18 @@ type StageContext struct {
|
|||||||
Memory []MemItem
|
Memory []MemItem
|
||||||
NoMemory bool
|
NoMemory bool
|
||||||
Extra map[string]interface{}
|
Extra map[string]interface{}
|
||||||
Errors []string // 阶段处理过程中的错误信息
|
Errors []string // 阶段处理过程中的错误信息
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *StageContext) RLock() { c.mu.RLock() }
|
func (c *StageContext) RLock() { c.mu.RLock() }
|
||||||
func (c *StageContext) RUnlock() { c.mu.RUnlock() }
|
func (c *StageContext) RUnlock() { c.mu.RUnlock() }
|
||||||
func (c *StageContext) Lock() { c.mu.Lock() }
|
func (c *StageContext) Lock() { c.mu.Lock() }
|
||||||
func (c *StageContext) Unlock() { c.mu.Unlock() }
|
func (c *StageContext) Unlock() { c.mu.Unlock() }
|
||||||
func (c *StageContext) IsResponded() bool { c.mu.RLock(); defer c.mu.RUnlock(); return c.Response != nil }
|
func (c *StageContext) IsResponded() bool {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.Response != nil
|
||||||
|
}
|
||||||
|
|
||||||
// MemItem represents a memory item in stage context.
|
// MemItem represents a memory item in stage context.
|
||||||
type MemItem struct {
|
type MemItem struct {
|
||||||
@ -96,12 +151,13 @@ type ToolResult struct {
|
|||||||
|
|
||||||
// ToolDef describes a tool that the plugin exposes.
|
// ToolDef describes a tool that the plugin exposes.
|
||||||
type ToolDef struct {
|
type ToolDef struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Plugin string `json:"plugin,omitempty"`
|
Plugin string `json:"plugin,omitempty"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Parameters map[string]interface{} `json:"parameters"`
|
Parameters map[string]interface{} `json:"parameters"`
|
||||||
NoMemory bool `json:"no_memory,omitempty"` // 此工具输出不参与记忆计算,但原文保留
|
NoMemory bool `json:"no_memory,omitempty"` // 此工具输出不参与记忆计算,但原文保留
|
||||||
Cleaner func(string) string `json:"-"` // 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏时调用
|
Cleaner func(string) string `json:"-"` // 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏时调用
|
||||||
|
ContextPolicy string `json:"context_policy,omitempty"` // 上下文策略:""(默认,不裁剪) / ContextPolicyNone / ContextPolicyPrune
|
||||||
}
|
}
|
||||||
|
|
||||||
// IOInjector provides methods for injecting input and interrupts into the agent pipeline.
|
// IOInjector provides methods for injecting input and interrupts into the agent pipeline.
|
||||||
@ -117,6 +173,20 @@ type IOInjector interface {
|
|||||||
// SetToolBlocks 插件工具注入多模态内容块(image_url/audio_url),内核在下一条
|
// SetToolBlocks 插件工具注入多模态内容块(image_url/audio_url),内核在下一条
|
||||||
// tool message 的 content 数组里带上这些块,让模型在后续轮次看到图/听到音频。
|
// tool message 的 content 数组里带上这些块,让模型在后续轮次看到图/听到音频。
|
||||||
SetToolBlocks(blocks []ContentBlock)
|
SetToolBlocks(blocks []ContentBlock)
|
||||||
|
InjectInputMedia(source, channel, text string, blocks []ContentBlock)
|
||||||
|
InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
|
||||||
|
InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
|
||||||
|
|
||||||
|
// 以下 Opts 变体让调用点在**这一次注入**上声明记忆与裁剪行为。
|
||||||
|
//
|
||||||
|
// 上面那些不带 opts 的方法等价于传零值 InjectOptions(记入记忆 + 不裁剪),
|
||||||
|
// 保留它们是为了不破坏已有插件;新代码应当用 Opts 变体把意图写清楚。
|
||||||
|
InjectTextOpts(source, channel, text string, opts InjectOptions)
|
||||||
|
InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
|
||||||
|
InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
|
||||||
|
InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||||
|
InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
|
||||||
|
InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EventType identifies the kind of system event.
|
// EventType identifies the kind of system event.
|
||||||
@ -221,6 +291,24 @@ type PluginSDK struct {
|
|||||||
events EventSubscriber
|
events EventSubscriber
|
||||||
plgMgr PluginMgrAPI
|
plgMgr PluginMgrAPI
|
||||||
|
|
||||||
|
// apiMu 保护上面这些由内核注入的 API 字段,以及 autoRestart。
|
||||||
|
//
|
||||||
|
// 这些字段的写方与读方天然跨 goroutine:
|
||||||
|
// - 写方是内核(加载/重载插件时注入 API)与插件自己(SetAutoRestart);
|
||||||
|
// - 读方是插件在 Start() 里起的后台 goroutine(轮询、监听、定时器
|
||||||
|
// 都要拿 injector 往管道里注消息),以及内核 registry —— 它在
|
||||||
|
// 另一个 goroutine 读 AutoRestart() 决定崩溃后是否重启。
|
||||||
|
// SetAutoRestart 的文档用法本身就是「连接建立后再决定能否自动重启」,
|
||||||
|
// 而连接建立通常发生在后台 goroutine 里,于是这对读写必然并发。
|
||||||
|
//
|
||||||
|
// sdk/stress_test.go 的 -race 实测确认这是真竞态,不是理论风险。
|
||||||
|
// 未加锁时的生产表现是偶发 nil 解引用崩溃(读到半个接口值)。
|
||||||
|
//
|
||||||
|
// 约定:只在持锁期间取字段值,取完立刻释放再调用。
|
||||||
|
// 持锁调用会把 InjectInputSync 这类阻塞到 agent 回复(可达数分钟)的
|
||||||
|
// 方法与 SetIOInjector 串到一起,让插件重载卡死。
|
||||||
|
apiMu sync.RWMutex
|
||||||
|
|
||||||
autoRestart bool
|
autoRestart bool
|
||||||
|
|
||||||
stopMu sync.Mutex
|
stopMu sync.Mutex
|
||||||
@ -247,28 +335,57 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
|
|||||||
func (s *PluginSDK) PluginName() string { return s.name }
|
func (s *PluginSDK) PluginName() string { return s.name }
|
||||||
|
|
||||||
// Settings returns the settings API for reading/writing plugin configuration.
|
// Settings returns the settings API for reading/writing plugin configuration.
|
||||||
|
// sett 在 New 时一次性写入且无 setter,故不需要加锁。
|
||||||
func (s *PluginSDK) Settings() SettingsAPI { return s.sett }
|
func (s *PluginSDK) Settings() SettingsAPI { return s.sett }
|
||||||
|
|
||||||
// Memory returns the graph memory API (may be nil if not available).
|
// Memory returns the graph memory API (may be nil if not available).
|
||||||
func (s *PluginSDK) Memory() MemoryAPI { return s.mem }
|
func (s *PluginSDK) Memory() MemoryAPI {
|
||||||
|
s.apiMu.RLock()
|
||||||
|
defer s.apiMu.RUnlock()
|
||||||
|
return s.mem
|
||||||
|
}
|
||||||
|
|
||||||
// TextMemory returns the text memory API (may be nil if not available).
|
// TextMemory returns the text memory API (may be nil if not available).
|
||||||
func (s *PluginSDK) TextMemory() TextMemoryAPI { return s.textMem }
|
func (s *PluginSDK) TextMemory() TextMemoryAPI {
|
||||||
|
s.apiMu.RLock()
|
||||||
|
defer s.apiMu.RUnlock()
|
||||||
|
return s.textMem
|
||||||
|
}
|
||||||
|
|
||||||
// DocMemory returns the document memory API (may be nil if not available).
|
// DocMemory returns the document memory API (may be nil if not available).
|
||||||
func (s *PluginSDK) DocMemory() DocMemoryAPI { return s.docMem }
|
func (s *PluginSDK) DocMemory() DocMemoryAPI {
|
||||||
|
s.apiMu.RLock()
|
||||||
|
defer s.apiMu.RUnlock()
|
||||||
|
return s.docMem
|
||||||
|
}
|
||||||
|
|
||||||
// Knowledge returns the knowledge store API (may be nil if not available).
|
// Knowledge returns the knowledge store API (may be nil if not available).
|
||||||
func (s *PluginSDK) Knowledge() KnowledgeAPI { return s.know }
|
func (s *PluginSDK) Knowledge() KnowledgeAPI {
|
||||||
|
s.apiMu.RLock()
|
||||||
|
defer s.apiMu.RUnlock()
|
||||||
|
return s.know
|
||||||
|
}
|
||||||
|
|
||||||
// LLM returns the LLM provider API (may be nil if not available).
|
// LLM returns the LLM provider API (may be nil if not available).
|
||||||
func (s *PluginSDK) LLM() LLMAPI { return s.llm }
|
func (s *PluginSDK) LLM() LLMAPI {
|
||||||
|
s.apiMu.RLock()
|
||||||
|
defer s.apiMu.RUnlock()
|
||||||
|
return s.llm
|
||||||
|
}
|
||||||
|
|
||||||
// Social returns the social graph API (may be nil if not available).
|
// Social returns the social graph API (may be nil if not available).
|
||||||
func (s *PluginSDK) Social() SocialAPI { return s.social }
|
func (s *PluginSDK) Social() SocialAPI {
|
||||||
|
s.apiMu.RLock()
|
||||||
|
defer s.apiMu.RUnlock()
|
||||||
|
return s.social
|
||||||
|
}
|
||||||
|
|
||||||
// Events returns the event subscriber for listening to kernel events (may be nil if not available).
|
// Events returns the event subscriber for listening to kernel events (may be nil if not available).
|
||||||
func (s *PluginSDK) Events() EventSubscriber { return s.events }
|
func (s *PluginSDK) Events() EventSubscriber {
|
||||||
|
s.apiMu.RLock()
|
||||||
|
defer s.apiMu.RUnlock()
|
||||||
|
return s.events
|
||||||
|
}
|
||||||
|
|
||||||
// RegisterTool registers a tool that the LLM can call.
|
// RegisterTool registers a tool that the LLM can call.
|
||||||
func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler) error {
|
func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler) error {
|
||||||
@ -282,8 +399,9 @@ func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RegisterStage registers a handler for a pipeline stage.
|
// RegisterStage registers a handler for a pipeline stage.
|
||||||
// scope: StageScopeGlobal (default) — receives all stage events.
|
//
|
||||||
// StageScopeOwnTools — only before_toolcall/after_toolcall for this plugin's tools.
|
// scope: StageScopeGlobal (default) — receives all stage events.
|
||||||
|
// StageScopeOwnTools — only before_toolcall/after_toolcall for this plugin's tools.
|
||||||
func (s *PluginSDK) RegisterStage(stage Stage, handler StageHandler, scope ...StageScope) {
|
func (s *PluginSDK) RegisterStage(stage Stage, handler StageHandler, scope ...StageScope) {
|
||||||
if s.regStage == nil {
|
if s.regStage == nil {
|
||||||
return
|
return
|
||||||
@ -333,8 +451,11 @@ func (s *PluginSDK) RegisterPluginAPI(name string) error {
|
|||||||
// def: 通道在记忆计算层的行为(NoMemory/Cleaner)
|
// def: 通道在记忆计算层的行为(NoMemory/Cleaner)
|
||||||
// handler: receives args map with keys: payload (string), type (string), meta (string|optional)
|
// handler: receives args map with keys: payload (string), type (string), meta (string|optional)
|
||||||
func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error {
|
func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error {
|
||||||
if s.regOutput != nil {
|
s.apiMu.RLock()
|
||||||
return s.regOutput(name, caps, desc, def, handler)
|
reg := s.regOutput
|
||||||
|
s.apiMu.RUnlock()
|
||||||
|
if reg != nil {
|
||||||
|
return reg(name, caps, desc, def, handler)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -343,76 +464,229 @@ func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, de
|
|||||||
// def.NoMemory: 此通道输入不参与记忆计算
|
// def.NoMemory: 此通道输入不参与记忆计算
|
||||||
// def.Cleaner: 计算层对输入文本清洗后(不改原文)再向量化/提关键词
|
// def.Cleaner: 计算层对输入文本清洗后(不改原文)再向量化/提关键词
|
||||||
func (s *PluginSDK) RegisterInputChannel(name string, def ChannelDef) error {
|
func (s *PluginSDK) RegisterInputChannel(name string, def ChannelDef) error {
|
||||||
if s.regInput != nil {
|
s.apiMu.RLock()
|
||||||
return s.regInput(name, def)
|
reg := s.regInput
|
||||||
|
s.apiMu.RUnlock()
|
||||||
|
if reg != nil {
|
||||||
|
return reg(name, def)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 以下 setter 由内核在启动/重载时调用,与插件后台 goroutine 的读并发,故加锁。
|
||||||
|
|
||||||
// SetOutputChannelRegistrar sets the output channel registrar (called by the core at startup).
|
// SetOutputChannelRegistrar sets the output channel registrar (called by the core at startup).
|
||||||
func (s *PluginSDK) SetOutputChannelRegistrar(r OutputChannelRegistrar) { s.regOutput = r }
|
func (s *PluginSDK) SetOutputChannelRegistrar(r OutputChannelRegistrar) {
|
||||||
|
s.apiMu.Lock()
|
||||||
|
s.regOutput = r
|
||||||
|
s.apiMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// SetInputChannelRegistrar sets the input channel registrar (called by the core at startup).
|
// SetInputChannelRegistrar sets the input channel registrar (called by the core at startup).
|
||||||
func (s *PluginSDK) SetInputChannelRegistrar(r InputChannelRegistrar) { s.regInput = r }
|
func (s *PluginSDK) SetInputChannelRegistrar(r InputChannelRegistrar) {
|
||||||
|
s.apiMu.Lock()
|
||||||
|
s.regInput = r
|
||||||
|
s.apiMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// SetIOInjector sets the IO injector (called by the core at startup).
|
// SetIOInjector sets the IO injector (called by the core at startup).
|
||||||
func (s *PluginSDK) SetIOInjector(io IOInjector) { s.io = io }
|
func (s *PluginSDK) SetIOInjector(io IOInjector) {
|
||||||
|
s.apiMu.Lock()
|
||||||
|
s.io = io
|
||||||
|
s.apiMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// SetMemoryAPI sets the memory API (called by the core at startup).
|
// SetMemoryAPI sets the memory API (called by the core at startup).
|
||||||
func (s *PluginSDK) SetMemoryAPI(mem MemoryAPI) { s.mem = mem }
|
func (s *PluginSDK) SetMemoryAPI(mem MemoryAPI) {
|
||||||
func (s *PluginSDK) SetTextMemoryAPI(tm TextMemoryAPI) { s.textMem = tm }
|
s.apiMu.Lock()
|
||||||
func (s *PluginSDK) SetDocMemoryAPI(dm DocMemoryAPI) { s.docMem = dm }
|
s.mem = mem
|
||||||
func (s *PluginSDK) SetKnowledgeAPI(kn KnowledgeAPI) { s.know = kn }
|
s.apiMu.Unlock()
|
||||||
func (s *PluginSDK) SetLLMAPI(llm LLMAPI) { s.llm = llm }
|
}
|
||||||
func (s *PluginSDK) SetSocialAPI(social SocialAPI) { s.social = social }
|
|
||||||
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber) { s.events = es }
|
func (s *PluginSDK) SetTextMemoryAPI(tm TextMemoryAPI) {
|
||||||
|
s.apiMu.Lock()
|
||||||
|
s.textMem = tm
|
||||||
|
s.apiMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PluginSDK) SetDocMemoryAPI(dm DocMemoryAPI) {
|
||||||
|
s.apiMu.Lock()
|
||||||
|
s.docMem = dm
|
||||||
|
s.apiMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PluginSDK) SetKnowledgeAPI(kn KnowledgeAPI) {
|
||||||
|
s.apiMu.Lock()
|
||||||
|
s.know = kn
|
||||||
|
s.apiMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PluginSDK) SetLLMAPI(llm LLMAPI) {
|
||||||
|
s.apiMu.Lock()
|
||||||
|
s.llm = llm
|
||||||
|
s.apiMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PluginSDK) SetSocialAPI(social SocialAPI) {
|
||||||
|
s.apiMu.Lock()
|
||||||
|
s.social = social
|
||||||
|
s.apiMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber) {
|
||||||
|
s.apiMu.Lock()
|
||||||
|
s.events = es
|
||||||
|
s.apiMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// SetPluginMgrAPI sets the plugin manager API (called by the bridge at startup).
|
// SetPluginMgrAPI sets the plugin manager API (called by the bridge at startup).
|
||||||
func (s *PluginSDK) SetPluginMgrAPI(pm PluginMgrAPI) { s.plgMgr = pm }
|
func (s *PluginSDK) SetPluginMgrAPI(pm PluginMgrAPI) {
|
||||||
|
s.apiMu.Lock()
|
||||||
|
s.plgMgr = pm
|
||||||
|
s.apiMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// PluginMgr returns the plugin manager API (ReloadOne / ReloadPlugins / list).
|
// PluginMgr returns the plugin manager API (ReloadOne / ReloadPlugins / list).
|
||||||
// May be nil if the host did not wire it.
|
// May be nil if the host did not wire it.
|
||||||
func (s *PluginSDK) PluginMgr() PluginMgrAPI { return s.plgMgr }
|
func (s *PluginSDK) PluginMgr() PluginMgrAPI {
|
||||||
|
s.apiMu.RLock()
|
||||||
|
defer s.apiMu.RUnlock()
|
||||||
|
return s.plgMgr
|
||||||
|
}
|
||||||
|
|
||||||
// ---- IO Convenience Methods ----
|
// ---- IO Convenience Methods ----
|
||||||
|
|
||||||
|
// injector 取当前 injector 的快照。
|
||||||
|
//
|
||||||
|
// 取完即释放锁再调用:InjectInputSync 会阻塞到 agent 回复(可达数分钟),
|
||||||
|
// 若持锁调用,插件重载时的 SetIOInjector 会一起卡住。
|
||||||
|
func (s *PluginSDK) injector() IOInjector {
|
||||||
|
s.apiMu.RLock()
|
||||||
|
defer s.apiMu.RUnlock()
|
||||||
|
return s.io
|
||||||
|
}
|
||||||
|
|
||||||
// InjectInterruptText injects a text interrupt that can preempt current LLM processing.
|
// InjectInterruptText injects a text interrupt that can preempt current LLM processing.
|
||||||
|
// 等价于 InjectInterruptTextOpts(..., InjectOptions{}):记入记忆、不裁剪。
|
||||||
func (s *PluginSDK) InjectInterruptText(source, channel, text string) {
|
func (s *PluginSDK) InjectInterruptText(source, channel, text string) {
|
||||||
if s.io != nil {
|
s.InjectInterruptTextOpts(source, channel, text, InjectOptions{})
|
||||||
s.io.InjectInterruptText(source, channel, text)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// InjectText injects a text message into the agent pipeline.
|
// InjectText injects a text message into the agent pipeline.
|
||||||
|
// 等价于 InjectTextOpts(..., InjectOptions{}):记入记忆、不裁剪。
|
||||||
func (s *PluginSDK) InjectText(source, channel, text string) {
|
func (s *PluginSDK) InjectText(source, channel, text string) {
|
||||||
if s.io != nil {
|
s.InjectTextOpts(source, channel, text, InjectOptions{})
|
||||||
s.io.InjectText(source, channel, text)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// InjectTextNoMemory injects a text message without generating memory.
|
// InjectTextNoMemory injects a text message without generating memory.
|
||||||
|
// 等价于 InjectTextOpts(..., InjectOptions{NoMemory: true})。
|
||||||
func (s *PluginSDK) InjectTextNoMemory(source, channel, text string) {
|
func (s *PluginSDK) InjectTextNoMemory(source, channel, text string) {
|
||||||
if s.io != nil {
|
s.InjectTextOpts(source, channel, text, InjectOptions{NoMemory: true})
|
||||||
s.io.InjectTextNoMemory(source, channel, text)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// InjectInputSync injects a text message and synchronously waits for the agent reply,
|
// InjectInputSync injects a text message and synchronously waits for the agent reply,
|
||||||
// returning the reply text (empty string if none). Replies must be dispatched back
|
// returning the reply text (empty string if none). Replies must be dispatched back
|
||||||
// to the source channel by the caller.
|
// to the source channel by the caller.
|
||||||
func (s *PluginSDK) InjectInputSync(source, channel, text string) string {
|
func (s *PluginSDK) InjectInputSync(source, channel, text string) string {
|
||||||
if s.io == nil {
|
return s.InjectInputSyncOpts(source, channel, text, InjectOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// InjectInputMedia 注入带媒体内容块(image_url/audio_url)的输入。
|
||||||
|
// blocks 会落进媒体存储被记忆引用捕获,同时作为当前轮 content 数组
|
||||||
|
// 发给 LLM,让模型在「本轮」就看到图/听到音频——区别于 SetToolBlocks
|
||||||
|
// 的「下一轮 tool message」语义。
|
||||||
|
// 等价于 InjectInputMediaOpts(..., InjectOptions{})。
|
||||||
|
func (s *PluginSDK) InjectInputMedia(source, channel, text string, blocks []ContentBlock) {
|
||||||
|
s.InjectInputMediaOpts(source, channel, text, blocks, InjectOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// InjectInputMediaSync 注入带媒体内容块的输入并同步等待 agent 回复。
|
||||||
|
// 等价于 InjectInputMediaSyncOpts(..., InjectOptions{})。
|
||||||
|
func (s *PluginSDK) InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string {
|
||||||
|
return s.InjectInputMediaSyncOpts(source, channel, text, blocks, InjectOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 带 InjectOptions 的注入(声明记忆/裁剪行为)----
|
||||||
|
|
||||||
|
// InjectTextOpts 注入文本到 agent,并在这一次注入上声明记忆与裁剪行为。
|
||||||
|
func (s *PluginSDK) InjectTextOpts(source, channel, text string, opts InjectOptions) {
|
||||||
|
if io := s.injector(); io != nil {
|
||||||
|
io.InjectTextOpts(source, channel, text, opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InjectInterruptTextOpts 注入可抢占当前处理的中断文本。
|
||||||
|
//
|
||||||
|
// 中断也允许声明 ContextPolicyPrune:中断同样携带内容进入上下文,
|
||||||
|
// 是否需要据此裁剪由调用方决定(默认不裁剪)。
|
||||||
|
func (s *PluginSDK) InjectInterruptTextOpts(source, channel, text string, opts InjectOptions) {
|
||||||
|
if io := s.injector(); io != nil {
|
||||||
|
io.InjectInterruptTextOpts(source, channel, text, opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InjectInputSyncOpts 注入输入并同步等待回复,同时在这次注入上声明记忆/裁剪行为。
|
||||||
|
func (s *PluginSDK) InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string {
|
||||||
|
io := s.injector()
|
||||||
|
if io == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return s.io.InjectInputSync(source, channel, text)
|
return io.InjectInputSyncOpts(source, channel, text, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InjectInputMediaOpts 注入带媒体块的输入,并声明记忆/裁剪行为。
|
||||||
|
func (s *PluginSDK) InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) {
|
||||||
|
if io := s.injector(); io != nil {
|
||||||
|
io.InjectInputMediaOpts(source, channel, text, blocks, opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InjectInputMediaSyncOpts 注入带媒体块的输入并同步等待回复,同时声明记忆/裁剪行为。
|
||||||
|
func (s *PluginSDK) InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string {
|
||||||
|
io := s.injector()
|
||||||
|
if io == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return io.InjectInputMediaSyncOpts(source, channel, text, blocks, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InjectInterruptMediaOpts 注入带媒体块的中断,并声明记忆/裁剪行为。
|
||||||
|
func (s *PluginSDK) InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) {
|
||||||
|
if io := s.injector(); io != nil {
|
||||||
|
io.InjectInterruptMediaOpts(source, channel, text, blocks, opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InjectInterruptMedia 注入带媒体内容块的中断,可抢占当前 LLM 处理。
|
||||||
|
// blocks 随中断消息一起发给模型。
|
||||||
|
func (s *PluginSDK) InjectInterruptMedia(source, channel, text string, blocks []ContentBlock) {
|
||||||
|
if io := s.injector(); io != nil {
|
||||||
|
io.InjectInterruptMedia(source, channel, text, blocks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetToolBlocks 在工具处理函数内注入多模态内容块,内核在下一条 tool message
|
||||||
|
// 的 content 数组里带上它们。需要「本轮就让模型看到」时用 InjectInputMedia。
|
||||||
|
func (s *PluginSDK) SetToolBlocks(blocks []ContentBlock) {
|
||||||
|
if io := s.injector(); io != nil {
|
||||||
|
io.SetToolBlocks(blocks)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetAutoRestart 设置插件是否允许内核自动重启(崩溃后自动重载)。
|
// SetAutoRestart 设置插件是否允许内核自动重启(崩溃后自动重载)。
|
||||||
// 默认 true。如果插件有无法恢复的状态(如外部连接),应设为 false。
|
// 默认 true。如果插件有无法恢复的状态(如外部连接),应设为 false。
|
||||||
func (s *PluginSDK) SetAutoRestart(enabled bool) { s.autoRestart = enabled }
|
func (s *PluginSDK) SetAutoRestart(enabled bool) {
|
||||||
|
s.apiMu.Lock()
|
||||||
|
s.autoRestart = enabled
|
||||||
|
s.apiMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// AutoRestart 返回插件是否允许自动重启。
|
// AutoRestart 返回插件是否允许自动重启。
|
||||||
func (s *PluginSDK) AutoRestart() bool { return s.autoRestart }
|
func (s *PluginSDK) AutoRestart() bool {
|
||||||
|
s.apiMu.RLock()
|
||||||
|
defer s.apiMu.RUnlock()
|
||||||
|
return s.autoRestart
|
||||||
|
}
|
||||||
|
|
||||||
// RegisterStopHandler 注册插件停止阶段的清理回调。
|
// RegisterStopHandler 注册插件停止阶段的清理回调。
|
||||||
// 注册的 handler 会在插件 Stop() 之前按"后注册先执行"的顺序调用,
|
// 注册的 handler 会在插件 Stop() 之前按"后注册先执行"的顺序调用,
|
||||||
|
|||||||
760
sdk/stress_test.go
Normal file
760
sdk/stress_test.go
Normal file
@ -0,0 +1,760 @@
|
|||||||
|
package sdk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SDK 公开接口的并发压力测试(1.1.0 媒体接口上线后新增)。
|
||||||
|
//
|
||||||
|
// 为什么这一层需要压测:SDK 是**被多个 goroutine 同时使用的共享对象**。
|
||||||
|
// 一个插件的典型形态是 Start() 里起若干后台 goroutine(轮询、监听、定时器),
|
||||||
|
// 它们各自持同一个 *PluginSDK 往里注入消息;内核侧同时还有 stage 扇出、
|
||||||
|
// 工具调用、以及读 AutoRestart() 决定崩溃后是否重启。
|
||||||
|
// 单线程单测全绿不代表这些并发路径成立。
|
||||||
|
//
|
||||||
|
// 关注点不是吞吐数字,而是不变量:
|
||||||
|
// 1. 注入调用不丢、不串(媒体块必须与文本配对,不能张冠李戴)
|
||||||
|
// 2. 状态字段的读写不产生数据竞争(-race 下必须干净)
|
||||||
|
// 3. handler 注册/执行在并发下"恰好一次"
|
||||||
|
// 4. 跨进程 JSON 序列化对新媒体类型必须字节级往返一致
|
||||||
|
//
|
||||||
|
// 媒体接口尤其需要 3 与 4:媒体块要经 JSON 过子进程边界,
|
||||||
|
// 而 []byte 在 JSON 里是 base64,往返不一致的后果是图片静默损坏。
|
||||||
|
|
||||||
|
// ---------- 测试替身 ----------
|
||||||
|
|
||||||
|
// recordingInjector 记录每一次注入调用,用于验证"不丢不串"。
|
||||||
|
type recordingInjector struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
calls []injectCall
|
||||||
|
|
||||||
|
// 计数用原子量:并发路径上只增不减,可在不持锁时安全读。
|
||||||
|
nText, nMedia, nInterrupt, nSync atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type injectCall struct {
|
||||||
|
kind string // text / media / interruptMedia / sync ...
|
||||||
|
source string
|
||||||
|
channel string
|
||||||
|
text string
|
||||||
|
blocks []ContentBlock
|
||||||
|
opts InjectOptions // 调用点声明的记忆/裁剪行为
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingInjector) record(c injectCall) {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.calls = append(r.calls, c)
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingInjector) InjectInterruptText(s, c, t string) {
|
||||||
|
r.nInterrupt.Add(1)
|
||||||
|
r.record(injectCall{kind: "interruptText", source: s, channel: c, text: t})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingInjector) InjectText(s, c, t string) {
|
||||||
|
r.nText.Add(1)
|
||||||
|
r.record(injectCall{kind: "text", source: s, channel: c, text: t})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingInjector) InjectTextNoMemory(s, c, t string) {
|
||||||
|
r.nText.Add(1)
|
||||||
|
r.record(injectCall{kind: "textNoMem", source: s, channel: c, text: t})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingInjector) InjectInputSync(s, c, t string) string {
|
||||||
|
r.nSync.Add(1)
|
||||||
|
r.record(injectCall{kind: "sync", source: s, channel: c, text: t})
|
||||||
|
return "reply:" + t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingInjector) SetToolBlocks(blocks []ContentBlock) {
|
||||||
|
r.record(injectCall{kind: "toolBlocks", blocks: blocks})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingInjector) InjectInputMedia(s, c, t string, b []ContentBlock) {
|
||||||
|
r.nMedia.Add(1)
|
||||||
|
r.record(injectCall{kind: "media", source: s, channel: c, text: t, blocks: b})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingInjector) InjectInputMediaSync(s, c, t string, b []ContentBlock) string {
|
||||||
|
r.nMedia.Add(1)
|
||||||
|
r.nSync.Add(1)
|
||||||
|
r.record(injectCall{kind: "mediaSync", source: s, channel: c, text: t, blocks: b})
|
||||||
|
return "reply:" + t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingInjector) InjectInterruptMedia(s, c, t string, b []ContentBlock) {
|
||||||
|
r.nMedia.Add(1)
|
||||||
|
r.record(injectCall{kind: "interruptMedia", source: s, channel: c, text: t, blocks: b})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 带 InjectOptions 的注入:记录 opts 以便测试断言标志位确实传到了内核 ----
|
||||||
|
|
||||||
|
func (r *recordingInjector) InjectTextOpts(s, c, t string, o InjectOptions) {
|
||||||
|
r.nText.Add(1)
|
||||||
|
r.record(injectCall{kind: "textOpts", source: s, channel: c, text: t, opts: o})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingInjector) InjectInterruptTextOpts(s, c, t string, o InjectOptions) {
|
||||||
|
r.nInterrupt.Add(1)
|
||||||
|
r.record(injectCall{kind: "interruptTextOpts", source: s, channel: c, text: t, opts: o})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingInjector) InjectInputSyncOpts(s, c, t string, o InjectOptions) string {
|
||||||
|
r.nSync.Add(1)
|
||||||
|
r.record(injectCall{kind: "syncOpts", source: s, channel: c, text: t, opts: o})
|
||||||
|
return "reply:" + t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingInjector) InjectInputMediaOpts(s, c, t string, b []ContentBlock, o InjectOptions) {
|
||||||
|
r.nMedia.Add(1)
|
||||||
|
r.record(injectCall{kind: "mediaOpts", source: s, channel: c, text: t, blocks: b, opts: o})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingInjector) InjectInputMediaSyncOpts(s, c, t string, b []ContentBlock, o InjectOptions) string {
|
||||||
|
r.nMedia.Add(1)
|
||||||
|
r.nSync.Add(1)
|
||||||
|
r.record(injectCall{kind: "mediaSyncOpts", source: s, channel: c, text: t, blocks: b, opts: o})
|
||||||
|
return "reply:" + t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingInjector) InjectInterruptMediaOpts(s, c, t string, b []ContentBlock, o InjectOptions) {
|
||||||
|
r.nMedia.Add(1)
|
||||||
|
r.record(injectCall{kind: "interruptMediaOpts", source: s, channel: c, text: t, blocks: b, opts: o})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingInjector) snapshot() []injectCall {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return append([]injectCall{}, r.calls...)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ IOInjector = (*recordingInjector)(nil)
|
||||||
|
|
||||||
|
// imageBlock 构造一个带可识别 URL 的图片块。
|
||||||
|
func imageBlock(tag string) ContentBlock {
|
||||||
|
return ContentBlock{
|
||||||
|
Type: "image_url",
|
||||||
|
ImageURL: &ImageURL{URL: "data:image/png;base64," + tag, Detail: "auto"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 1. 媒体注入并发不丢不串 ----------
|
||||||
|
|
||||||
|
// 三个媒体注入方法在高并发下必须:调用数精确、且每次调用的 text 与 blocks 配对不错。
|
||||||
|
//
|
||||||
|
// "不串"是这里的关键断言。注入是插件里最容易被后台 goroutine 并发调用的入口,
|
||||||
|
// 若实现里出现任何共享中间状态(比如把 blocks 暂存到 SDK 字段再读出),
|
||||||
|
// 高并发下就会出现 A 的文本配上 B 的图——而两者单独看都"成功"了,不报错。
|
||||||
|
func TestStress_MediaInjectionConcurrentNoCrossTalk(t *testing.T) {
|
||||||
|
const workers, perWorker = 32, 200
|
||||||
|
|
||||||
|
inj := &recordingInjector{}
|
||||||
|
s := &PluginSDK{name: "stress"}
|
||||||
|
s.SetIOInjector(inj)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for w := 0; w < workers; w++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(w int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < perWorker; i++ {
|
||||||
|
// tag 唯一标识这次调用,文本与图片 URL 里都带上它。
|
||||||
|
tag := fmt.Sprintf("w%d-i%d", w, i)
|
||||||
|
switch i % 3 {
|
||||||
|
case 0:
|
||||||
|
s.InjectInputMedia("src", "ch", tag, []ContentBlock{imageBlock(tag)})
|
||||||
|
case 1:
|
||||||
|
if got := s.InjectInputMediaSync("src", "ch", tag, []ContentBlock{imageBlock(tag)}); got != "reply:"+tag {
|
||||||
|
t.Errorf("同步注入回复错位: got %q want %q", got, "reply:"+tag)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
s.InjectInterruptMedia("src", "ch", tag, []ContentBlock{imageBlock(tag)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(w)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
total := int64(workers * perWorker)
|
||||||
|
if got := inj.nMedia.Load(); got != total {
|
||||||
|
t.Fatalf("媒体注入调用数 = %d,期望 %d(有调用丢失)", got, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 逐条校验文本与媒体块配对:URL 必须含该次调用自己的 tag。
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, c := range inj.snapshot() {
|
||||||
|
if len(c.blocks) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.blocks[0].ImageURL == nil {
|
||||||
|
t.Fatalf("媒体块 ImageURL 丢失: %+v", c.blocks[0])
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(c.blocks[0].ImageURL.URL, c.text) {
|
||||||
|
t.Fatalf("文本与媒体块错位: text=%q url=%q", c.text, c.blocks[0].ImageURL.URL)
|
||||||
|
}
|
||||||
|
if seen[c.text] {
|
||||||
|
t.Fatalf("同一次调用被记录两次: %s", c.text)
|
||||||
|
}
|
||||||
|
seen[c.text] = true
|
||||||
|
}
|
||||||
|
if len(seen) != int(total) {
|
||||||
|
t.Fatalf("去重后调用数 = %d,期望 %d", len(seen), total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 2. 注入期间热替换 injector ----------
|
||||||
|
|
||||||
|
// 内核在插件运行期间可能重新注入 API(重载、恢复、子进程重连握手)。
|
||||||
|
// 此时插件的后台 goroutine 仍在注入。这条路径若无同步就是对 s.io 的数据竞争,
|
||||||
|
// 在 -race 下会被抓出;生产表现是偶发 nil 解引用崩溃。
|
||||||
|
func TestStress_InjectorSwapDuringInjection(t *testing.T) {
|
||||||
|
s := &PluginSDK{name: "stress"}
|
||||||
|
s.SetIOInjector(&recordingInjector{})
|
||||||
|
|
||||||
|
stop := make(chan struct{})
|
||||||
|
var injectors, swapper sync.WaitGroup
|
||||||
|
|
||||||
|
// 注入方:持续打直到 stop
|
||||||
|
for w := 0; w < 8; w++ {
|
||||||
|
injectors.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer injectors.Done()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
s.InjectInputMedia("src", "ch", "x", []ContentBlock{imageBlock("x")})
|
||||||
|
s.InjectText("src", "ch", "y")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 替换方:反复换 injector(含换成 nil——内核卸载 API 时的真实状态)
|
||||||
|
swapper.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer swapper.Done()
|
||||||
|
for i := 0; i < 500; i++ {
|
||||||
|
if i%7 == 0 {
|
||||||
|
s.SetIOInjector(nil)
|
||||||
|
} else {
|
||||||
|
s.SetIOInjector(&recordingInjector{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 先等替换跑完,再告知注入方退出。
|
||||||
|
// 顺序写反了就是死锁:注入方只依 close(stop) 退出。
|
||||||
|
swapper.Wait()
|
||||||
|
close(stop)
|
||||||
|
injectors.Wait()
|
||||||
|
// 断言就是「没崩、-race 没报」。nil injector 时必须静默跳过而非 panic。
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 3. autoRestart 标志的并发读写 ----------
|
||||||
|
|
||||||
|
// SetAutoRestart 的文档用途是"插件有无法恢复的状态(如外部连接)时设为 false"——
|
||||||
|
// 而连接建立本身通常是异步的,所以这个写入天然发生在后台 goroutine。
|
||||||
|
// 内核侧 registry 在另一个 goroutine 读 AutoRestart() 决定崩溃后是否重启。
|
||||||
|
// 这是一对跨 goroutine 的读写,必须同步。
|
||||||
|
func TestStress_AutoRestartFlagConcurrent(t *testing.T) {
|
||||||
|
s := &PluginSDK{name: "stress", autoRestart: true}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for w := 0; w < 16; w++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(w int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < 500; i++ {
|
||||||
|
s.SetAutoRestart(i%2 == 0)
|
||||||
|
}
|
||||||
|
}(w)
|
||||||
|
}
|
||||||
|
// 读方模拟内核 registry
|
||||||
|
for r := 0; r < 8; r++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < 500; i++ {
|
||||||
|
_ = s.AutoRestart()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 4. stop / onRemove handler 的"恰好一次" ----------
|
||||||
|
|
||||||
|
// RunStopHandlers 的契约是"执行后清空,幂等"。内核在停止插件时可能并发触发
|
||||||
|
// (超时强杀与正常 Stop 竞争),handler 里往往是关连接、落盘——
|
||||||
|
// 执行两次的后果从"重复写文件"到"close 已关闭的 channel 直接 panic"。
|
||||||
|
func TestStress_StopHandlersExactlyOnce(t *testing.T) {
|
||||||
|
const n = 300
|
||||||
|
s := &PluginSDK{name: "stress"}
|
||||||
|
|
||||||
|
var counters [n]atomic.Int64
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
i := i
|
||||||
|
s.RegisterStopHandler(func() { counters[i].Add(1) })
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for w := 0; w < 16; w++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
s.RunStopHandlers()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
if got := counters[i].Load(); got != 1 {
|
||||||
|
t.Fatalf("handler %d 执行 %d 次,期望恰好 1 次", i, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册与执行并发:已注册的 handler 一次都不能多跑,未跑到的也不能被丢。
|
||||||
|
// 断言用"每个 handler 的执行次数 <= 1"而非"总数相等"——
|
||||||
|
// 与 RunStopHandlers 竞争的注册可能落在快照之后,那属于合法的未执行。
|
||||||
|
func TestStress_StopHandlersRegisterWhileRunning(t *testing.T) {
|
||||||
|
s := &PluginSDK{name: "stress"}
|
||||||
|
const n = 500
|
||||||
|
var counters [n]atomic.Int64
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
i := i
|
||||||
|
s.RegisterStopHandler(func() { counters[i].Add(1) })
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
s.RunStopHandlers()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
wg.Wait()
|
||||||
|
s.RunStopHandlers() // 收尾:把剩下的都跑掉
|
||||||
|
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
if got := counters[i].Load(); got > 1 {
|
||||||
|
t.Fatalf("handler %d 被执行 %d 次(重复执行)", i, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStress_OnRemoveHandlersExactlyOnce(t *testing.T) {
|
||||||
|
const n = 200
|
||||||
|
s := &PluginSDK{name: "stress"}
|
||||||
|
|
||||||
|
var counters [n]atomic.Int64
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
i := i
|
||||||
|
s.RegisterOnRemoveHandler(func() { counters[i].Add(1) })
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for w := 0; w < 12; w++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
s.RunOnRemoveHandlers()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
if got := counters[i].Load(); got != 1 {
|
||||||
|
t.Fatalf("onRemove handler %d 执行 %d 次,期望恰好 1 次", i, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 5. StageContext 并发读改写 ----------
|
||||||
|
|
||||||
|
// StageContext 是全部 stage handler 共享的可变状态,字段全导出、靠调用方自觉
|
||||||
|
// 持 Lock/RLock。媒体链路让 Extra 成为新热点(media_blocks 挂在这里),
|
||||||
|
// 而 map 的并发写在 Go 里是直接 fatal,recover 都接不住。
|
||||||
|
//
|
||||||
|
// 这条测试锁定的不变量:按约定持锁的并发读改写不丢更新、不 fatal。
|
||||||
|
func TestStress_StageContextConcurrentExtraAndFinalText(t *testing.T) {
|
||||||
|
ctx := &StageContext{Extra: map[string]interface{}{}}
|
||||||
|
|
||||||
|
const workers, rounds = 16, 200
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for w := 0; w < workers; w++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(w int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < rounds; i++ {
|
||||||
|
// 写:模拟插件往 Extra 塞媒体块并追加文本(读-改-写)
|
||||||
|
ctx.Lock()
|
||||||
|
ctx.Extra[fmt.Sprintf("k%d-%d", w, i)] = []ContentBlock{imageBlock("x")}
|
||||||
|
ctx.FinalText += "."
|
||||||
|
ctx.Unlock()
|
||||||
|
|
||||||
|
// 读:模拟另一个 handler 检查是否已被响应
|
||||||
|
_ = ctx.IsResponded()
|
||||||
|
ctx.RLock()
|
||||||
|
_ = len(ctx.Extra)
|
||||||
|
ctx.RUnlock()
|
||||||
|
}
|
||||||
|
}(w)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
ctx.RLock()
|
||||||
|
defer ctx.RUnlock()
|
||||||
|
if len(ctx.Extra) != workers*rounds {
|
||||||
|
t.Fatalf("Extra 键数 = %d,期望 %d(出现 lost update)", len(ctx.Extra), workers*rounds)
|
||||||
|
}
|
||||||
|
if len(ctx.FinalText) != workers*rounds {
|
||||||
|
t.Fatalf("FinalText 长度 = %d,期望 %d(出现 lost update)", len(ctx.FinalText), workers*rounds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 6. OwnTools scope 包装器的并发正确性 ----------
|
||||||
|
|
||||||
|
// StageScopeOwnTools 的包装闭环里要读 ctx.ToolCalls 判断归属。
|
||||||
|
// 并发下若判断与执行之间状态被改写,就会出现"别人的工具触发了我的 handler"——
|
||||||
|
// 后果是插件对不属于自己的工具结果动手,且没有任何错误。
|
||||||
|
func TestStress_OwnToolsScopeNoCrossPluginLeak(t *testing.T) {
|
||||||
|
var registered StageHandler
|
||||||
|
s := &PluginSDK{
|
||||||
|
name: "mine",
|
||||||
|
regStage: func(stage Stage, h StageHandler) { registered = h },
|
||||||
|
}
|
||||||
|
|
||||||
|
var fired atomic.Int64
|
||||||
|
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error {
|
||||||
|
fired.Add(1)
|
||||||
|
ctx.RLock()
|
||||||
|
defer ctx.RUnlock()
|
||||||
|
// 触发了就必须确实是自己的工具
|
||||||
|
if len(ctx.ToolCalls) == 0 || ctx.ToolCalls[0].Plugin != "mine" {
|
||||||
|
t.Errorf("handler 被别的插件的工具触发: %+v", ctx.ToolCalls)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, StageScopeOwnTools)
|
||||||
|
|
||||||
|
if registered == nil {
|
||||||
|
t.Fatal("handler 未注册")
|
||||||
|
}
|
||||||
|
|
||||||
|
const workers, rounds = 16, 100
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mineCount atomic.Int64
|
||||||
|
for w := 0; w < workers; w++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(w int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < rounds; i++ {
|
||||||
|
// 每个 goroutine 用自己的 ctx——真实内核里 stage 扇出共享同一个
|
||||||
|
// ctx,但那部分的并发由内核 host 仲裁;这里验证包装器本身。
|
||||||
|
owner := "other"
|
||||||
|
if (w+i)%2 == 0 {
|
||||||
|
owner = "mine"
|
||||||
|
mineCount.Add(1)
|
||||||
|
}
|
||||||
|
ctx := &StageContext{Extra: map[string]interface{}{}}
|
||||||
|
ctx.ToolCalls = []ToolCall{{Plugin: owner, Name: "t"}}
|
||||||
|
if err := registered(ctx); err != nil {
|
||||||
|
t.Errorf("handler 返回错误: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(w)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if got, want := fired.Load(), mineCount.Load(); got != want {
|
||||||
|
t.Fatalf("handler 触发 %d 次,期望 %d 次(漏触发或跨插件触发)", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 7. 媒体类型的 JSON 往返(跨进程边界的真实形态) ----------
|
||||||
|
|
||||||
|
// 媒体块与附件要经 JSON 过子进程边界。[]byte 在 JSON 里是 base64,
|
||||||
|
// 往返不一致的后果是图片字节静默损坏——落进 CAS 后 digest 校验才会发现,
|
||||||
|
// 而那时已经无从追查是谁改坏的。
|
||||||
|
func TestStress_MediaTypesJSONRoundTripAtScale(t *testing.T) {
|
||||||
|
// 覆盖真实会遇到的边界:空、单字节、含 0x00、全 0xFF、超过 base64 分组边界的长度
|
||||||
|
sizes := []int{0, 1, 2, 3, 255, 256, 1023, 4096, 65537}
|
||||||
|
for _, n := range sizes {
|
||||||
|
data := make([]byte, n)
|
||||||
|
for i := range data {
|
||||||
|
data[i] = byte(i * 7 % 256)
|
||||||
|
}
|
||||||
|
att := MediaAttachment{
|
||||||
|
Digest: strings.Repeat("a", 64),
|
||||||
|
MIME: "image/png",
|
||||||
|
Data: data,
|
||||||
|
Name: "图片-名字 with space & 符号.png",
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(att)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("size=%d marshal: %v", n, err)
|
||||||
|
}
|
||||||
|
var back MediaAttachment
|
||||||
|
if err := json.Unmarshal(b, &back); err != nil {
|
||||||
|
t.Fatalf("size=%d unmarshal: %v", n, err)
|
||||||
|
}
|
||||||
|
if len(back.Data) != n {
|
||||||
|
t.Fatalf("size=%d 往返后长度 = %d", n, len(back.Data))
|
||||||
|
}
|
||||||
|
for i := range data {
|
||||||
|
if back.Data[i] != data[i] {
|
||||||
|
t.Fatalf("size=%d 第 %d 字节损坏: %02x != %02x", n, i, back.Data[i], data[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if back.Name != att.Name || back.MIME != att.MIME || back.Digest != att.Digest {
|
||||||
|
t.Fatalf("size=%d 元数据往返不一致: %+v", n, back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// omitempty 必须真的生效:读路径上内核不回 Data,若序列化仍产出 "data":null
|
||||||
|
// 之类的键,跨进程消息会凭空变大,且插件侧无法区分"没有字节"与"空字节"。
|
||||||
|
func TestStress_MediaTypesOmitEmpty(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
v interface{}
|
||||||
|
absent []string
|
||||||
|
present []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Triple 无媒体",
|
||||||
|
v: Triple{Subject: "甲方", Relation: "签署", Object: "合同"},
|
||||||
|
absent: []string{"media_digests", "sentence_text", "confidence", "subject_type", "object_type"},
|
||||||
|
present: []string{"subject", "relation", "object"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Triple 带媒体",
|
||||||
|
v: Triple{Subject: "甲方", Relation: "包含", Object: "图", MediaDigests: []string{"abc12345"}, SentenceText: "句子"},
|
||||||
|
absent: []string{"confidence"},
|
||||||
|
present: []string{"media_digests", "sentence_text"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Doc 读路径无字节",
|
||||||
|
v: Doc{ID: "d1", Title: "标题", Content: "正文", Attachments: []MediaAttachment{{Digest: "abc12345", MIME: "image/png"}}},
|
||||||
|
absent: []string{"\"data\"", "media_digests", "score"},
|
||||||
|
present: []string{"attachments", "digest", "mime"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TextEvent 无附件",
|
||||||
|
v: TextEvent{Role: "user", Content: "hi"},
|
||||||
|
absent: []string{"attachments", "channel"},
|
||||||
|
present: []string{"role", "content"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ContentBlock 纯文本",
|
||||||
|
v: ContentBlock{Type: "text", Text: "hi"},
|
||||||
|
absent: []string{"image_url", "audio_url"},
|
||||||
|
present: []string{"type", "text"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ContentBlock 图片",
|
||||||
|
v: imageBlock("AAA"),
|
||||||
|
absent: []string{"audio_url", "\"text\""},
|
||||||
|
present: []string{"image_url", "detail"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
b, err := json.Marshal(c.v)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s marshal: %v", c.name, err)
|
||||||
|
}
|
||||||
|
s := string(b)
|
||||||
|
for _, k := range c.absent {
|
||||||
|
if strings.Contains(s, k) {
|
||||||
|
t.Errorf("%s: 不该出现的键 %s —— %s", c.name, k, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, k := range c.present {
|
||||||
|
if !strings.Contains(s, k) {
|
||||||
|
t.Errorf("%s: 缺少键 %s —— %s", c.name, k, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 媒体块在并发序列化下必须各自独立:ImageURL/AudioURL 是指针,
|
||||||
|
// 若某处复用同一个指针再改写,序列化结果会互相污染。
|
||||||
|
func TestStress_ContentBlockConcurrentMarshal(t *testing.T) {
|
||||||
|
const workers, rounds = 16, 300
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for w := 0; w < workers; w++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(w int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < rounds; i++ {
|
||||||
|
tag := fmt.Sprintf("w%d-i%d", w, i)
|
||||||
|
blocks := []ContentBlock{
|
||||||
|
{Type: "text", Text: tag},
|
||||||
|
imageBlock(tag),
|
||||||
|
{Type: "audio_url", AudioURL: &AudioURL{URL: "data:audio/wav;base64," + tag}},
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(blocks)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("marshal: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var back []ContentBlock
|
||||||
|
if err := json.Unmarshal(b, &back); err != nil {
|
||||||
|
t.Errorf("unmarshal: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(back) != 3 {
|
||||||
|
t.Errorf("块数 = %d", len(back))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if back[0].ImageURL != nil || back[0].AudioURL != nil {
|
||||||
|
t.Errorf("文本块被填了媒体指针: %+v", back[0])
|
||||||
|
}
|
||||||
|
if back[1].ImageURL == nil || !strings.HasSuffix(back[1].ImageURL.URL, tag) {
|
||||||
|
t.Errorf("图片块 URL 错位: %+v", back[1].ImageURL)
|
||||||
|
}
|
||||||
|
if back[1].AudioURL != nil {
|
||||||
|
t.Errorf("图片块被填了音频指针")
|
||||||
|
}
|
||||||
|
if back[2].AudioURL == nil || !strings.HasSuffix(back[2].AudioURL.URL, tag) {
|
||||||
|
t.Errorf("音频块 URL 错位: %+v", back[2].AudioURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(w)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 8. 注册面的并发 ----------
|
||||||
|
|
||||||
|
// 插件在 Start() 里起多个 goroutine 分别注册工具是常见写法。
|
||||||
|
// def.Plugin 的默认填充若不是每次调用独立的,就会出现工具归属错乱——
|
||||||
|
// 表现是 OwnTools scope 失效、WebUI 里工具挂在别的插件名下。
|
||||||
|
func TestStress_RegisterToolConcurrentPluginDefaulting(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
got := map[string]string{} // toolName -> def.Plugin
|
||||||
|
|
||||||
|
s := &PluginSDK{
|
||||||
|
name: "mine",
|
||||||
|
regTool: func(name string, def ToolDef, h ToolHandler) error {
|
||||||
|
mu.Lock()
|
||||||
|
got[name] = def.Plugin
|
||||||
|
mu.Unlock()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const workers, perWorker = 16, 100
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for w := 0; w < workers; w++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(w int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < perWorker; i++ {
|
||||||
|
name := fmt.Sprintf("tool_w%d_i%d", w, i)
|
||||||
|
def := ToolDef{Description: "d", Parameters: map[string]interface{}{}}
|
||||||
|
// 一半显式指定归属,一半靠 SDK 填默认值
|
||||||
|
if i%2 == 0 {
|
||||||
|
def.Plugin = "explicit"
|
||||||
|
}
|
||||||
|
if err := s.RegisterTool(name, def, func(map[string]interface{}) (interface{}, error) {
|
||||||
|
return nil, nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Errorf("RegisterTool: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(w)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if len(got) != workers*perWorker {
|
||||||
|
t.Fatalf("注册工具数 = %d,期望 %d", len(got), workers*perWorker)
|
||||||
|
}
|
||||||
|
for name, owner := range got {
|
||||||
|
want := "mine"
|
||||||
|
if isEvenSuffix(name) {
|
||||||
|
want = "explicit"
|
||||||
|
}
|
||||||
|
if owner != want {
|
||||||
|
t.Fatalf("工具 %s 归属 = %q,期望 %q", name, owner, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isEvenSuffix 判断 tool_wX_iY 里的 Y 是否为偶数。
|
||||||
|
func isEvenSuffix(name string) bool {
|
||||||
|
idx := strings.LastIndex(name, "_i")
|
||||||
|
if idx < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
if _, err := fmt.Sscanf(name[idx+2:], "%d", &n); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return n%2 == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// nil 依赖下所有便捷方法必须静默降级而非 panic。
|
||||||
|
//
|
||||||
|
// 这是"媒体存储可关闭"在 SDK 层的对应物:内核未注入某个 API 时
|
||||||
|
// (精简部署、插件权限不足、子进程握手尚未完成),插件的调用不该崩。
|
||||||
|
func TestStress_NilDependenciesDegradeSilently(t *testing.T) {
|
||||||
|
s := &PluginSDK{name: "bare"}
|
||||||
|
|
||||||
|
const workers = 16
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for w := 0; w < workers; w++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
s.InjectText("s", "c", "t")
|
||||||
|
s.InjectTextNoMemory("s", "c", "t")
|
||||||
|
s.InjectInterruptText("s", "c", "t")
|
||||||
|
if got := s.InjectInputSync("s", "c", "t"); got != "" {
|
||||||
|
t.Errorf("无 injector 时同步注入应返回空串,got %q", got)
|
||||||
|
}
|
||||||
|
s.InjectInputMedia("s", "c", "t", []ContentBlock{imageBlock("x")})
|
||||||
|
if got := s.InjectInputMediaSync("s", "c", "t", nil); got != "" {
|
||||||
|
t.Errorf("无 injector 时媒体同步注入应返回空串,got %q", got)
|
||||||
|
}
|
||||||
|
s.InjectInterruptMedia("s", "c", "t", nil)
|
||||||
|
|
||||||
|
// getter 全部应返回 nil 而非 panic
|
||||||
|
_ = s.Memory()
|
||||||
|
_ = s.TextMemory()
|
||||||
|
_ = s.DocMemory()
|
||||||
|
_ = s.Knowledge()
|
||||||
|
_ = s.LLM()
|
||||||
|
_ = s.Social()
|
||||||
|
_ = s.Events()
|
||||||
|
_ = s.PluginMgr()
|
||||||
|
_ = s.Settings()
|
||||||
|
|
||||||
|
// 注册面无 registrar 时应返回 nil error
|
||||||
|
if err := s.RegisterTool("t", ToolDef{}, nil); err != nil {
|
||||||
|
t.Errorf("无 registrar 时 RegisterTool 应返回 nil,got %v", err)
|
||||||
|
}
|
||||||
|
if err := s.RegisterPluginAPI("a"); err != nil {
|
||||||
|
t.Errorf("无 registrar 时 RegisterPluginAPI 应返回 nil,got %v", err)
|
||||||
|
}
|
||||||
|
s.RegisterStage(StageOnInput, func(*StageContext) error { return nil })
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
@ -20,11 +20,21 @@ type BuildConfig struct {
|
|||||||
Replaces []string
|
Replaces []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildFailed 记录本次构建是否有平台失败。
|
||||||
|
//
|
||||||
|
// 为什么要它:这两个构建函数遇到错误只是 Printf 后 return,而 cmdBuild 返回
|
||||||
|
// void,于是**构建失败却以 0 退出**。调用方(批量重编脚本、CI、发版脚本)
|
||||||
|
// 只能靠翻日志发现失败——实测中一个示例的 windows 目标编译失败,脚本却报
|
||||||
|
// 「17/17 全绿」,并因此少产出 16 个 .hmap。
|
||||||
|
// 判成功要看退出码,不能靠人读日志。
|
||||||
|
var buildFailed bool
|
||||||
|
|
||||||
func cmdBuild(args []string) {
|
func cmdBuild(args []string) {
|
||||||
// Read all config from plg.json first
|
// Read all config from plg.json first
|
||||||
plg, err := readPlgJSON("plg.json")
|
plg, err := readPlgJSON("plg.json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("error: read plg.json: %v\n", err); os.Exit(1)
|
fmt.Printf("error: read plg.json: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Base config from plg.json
|
// Base config from plg.json
|
||||||
@ -39,11 +49,13 @@ func cmdBuild(args []string) {
|
|||||||
switch args[i] {
|
switch args[i] {
|
||||||
case "--outdir":
|
case "--outdir":
|
||||||
if i+1 < len(args) {
|
if i+1 < len(args) {
|
||||||
outDir = args[i+1]; i++
|
outDir = args[i+1]
|
||||||
|
i++
|
||||||
}
|
}
|
||||||
case "--target":
|
case "--target":
|
||||||
if i+1 < len(args) {
|
if i+1 < len(args) {
|
||||||
targets = append(targets, args[i+1]); i++
|
targets = append(targets, args[i+1])
|
||||||
|
i++
|
||||||
}
|
}
|
||||||
case "--bundle":
|
case "--bundle":
|
||||||
bundle = true
|
bundle = true
|
||||||
@ -51,11 +63,13 @@ func cmdBuild(args []string) {
|
|||||||
bundle = false
|
bundle = false
|
||||||
case "--sdk-path":
|
case "--sdk-path":
|
||||||
if i+1 < len(args) {
|
if i+1 < len(args) {
|
||||||
sdkPath = args[i+1]; i++
|
sdkPath = args[i+1]
|
||||||
|
i++
|
||||||
}
|
}
|
||||||
case "--replace", "-R":
|
case "--replace", "-R":
|
||||||
if i+1 < len(args) {
|
if i+1 < len(args) {
|
||||||
cliReplaces = append(cliReplaces, args[i+1]); i++
|
cliReplaces = append(cliReplaces, args[i+1])
|
||||||
|
i++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -65,21 +79,28 @@ func cmdBuild(args []string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 项目可在 plg.json 里声明 sdk(中版本或完整版本,如 "1.2" / "1.2.1");
|
||||||
|
// 显式 --sdk-path / plg.json 的 sdk_path 优先 —— 那是直指源码目录,
|
||||||
|
// 常用于本机改 SDK 的联调场景。
|
||||||
|
if sdkPath == "" && strings.TrimSpace(plg.SDK) != "" {
|
||||||
|
dir, ver, err := ResolveSDKForProject(plg.SDK)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
sdkPath, plg.ResolvedSDK = dir, ver
|
||||||
|
fmt.Printf("[hmapdev] SDK %s(项目声明 sdk=%s)\n", ver, plg.SDK)
|
||||||
|
} else if sdkPath != "" && plg.ResolvedSDK == "" {
|
||||||
|
// 走的是显式路径:尽力记录它是哪版(读不到就不记,不因此失败)
|
||||||
|
plg.ResolvedSDK = normalizeSDKVersion(readMetaVersion(sdkPath))
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure go.mod exists with correct SDK path
|
// Ensure go.mod exists with correct SDK path
|
||||||
sdkModule := ensureGoMod(plg, sdkPath)
|
sdkModule := ensureGoMod(plg, sdkPath)
|
||||||
|
|
||||||
// First build: fetch the SDK module (generates go.sum with zip hash)
|
// 保证 SDK 模块可解析,否则编译必死在 "missing go.sum entry"。
|
||||||
if sdkModule != "" {
|
if sdkModule != "" {
|
||||||
if _, err := os.Stat("go.sum"); os.IsNotExist(err) {
|
ensureSDKResolvable(plg, sdkModule, sdkPath)
|
||||||
dl := exec.Command("go", "mod", "download", sdkModule)
|
|
||||||
dl.Env = os.Environ()
|
|
||||||
dl.Stdout = os.Stdout
|
|
||||||
dl.Stderr = os.Stderr
|
|
||||||
fmt.Println(" downloading SDK module deps...")
|
|
||||||
if err := dl.Run(); err != nil {
|
|
||||||
fmt.Printf(" error: go mod download: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge plg.json replaces + CLI overrides
|
// Merge plg.json replaces + CLI overrides
|
||||||
@ -96,23 +117,53 @@ func cmdBuild(args []string) {
|
|||||||
|
|
||||||
if bundle || len(targets) == 0 {
|
if bundle || len(targets) == 0 {
|
||||||
buildBundle(plg, outDir, sdkPath)
|
buildBundle(plg, outDir, sdkPath)
|
||||||
return
|
} else {
|
||||||
|
for _, t := range targets {
|
||||||
|
buildTarget(plg, t, outDir, sdkPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, t := range targets {
|
// 以非零码退出:调用方(批量重编、CI、发版脚本)靠退出码判成败。
|
||||||
buildTarget(plg, t, outDir, sdkPath)
|
// 以前这里直接 return,失败也退 0,于是「构建失败」只能靠人翻日志发现——
|
||||||
|
// 实测中就因此把一次部分失败当成了全绿。
|
||||||
|
if buildFailed {
|
||||||
|
fmt.Println("error: 至少一个目标构建失败(详见上面日志)")
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// allBundleTargets 是 --bundle 模式构建的全部平台。
|
// allBundleTargets 是 --bundle 模式构建的全部平台。
|
||||||
// 每个 OS 只有一个架构(amd64),避免二进制文件名冲突。
|
//
|
||||||
|
// 子进程模式下各平台产物同名(plugin.bin)——进程边界即 ABI 边界,
|
||||||
|
// 不存在平台特有扩展名,故 zip 内按平台加后缀区分;
|
||||||
|
// 内核安装时按当前平台挑对应条目重命名为 plugin.bin。
|
||||||
|
//
|
||||||
|
// **不含 windows**:插件只能运行在 homed 能跑的平台上,而 homed 已明确放弃
|
||||||
|
// Windows 原生支持(插件体系依赖 fd 继承 + 统一共享内存区的段内偏移,
|
||||||
|
// Windows 句柄模型无法表达)。Windows 用户走 WSL2,而 WSL2 就是 linux/amd64。
|
||||||
var allBundleTargets = []struct {
|
var allBundleTargets = []struct {
|
||||||
target string
|
target string
|
||||||
entry string // 二进制在 zip 中的文件名
|
entry string // 二进制在 zip 中的文件名
|
||||||
}{
|
}{
|
||||||
{"linux/amd64", "plugin.so"},
|
{"linux/amd64", "plugin.bin.linux.amd64"},
|
||||||
{"darwin/amd64", "plugin.dylib"},
|
{"darwin/amd64", "plugin.bin.darwin.amd64"},
|
||||||
{"windows/amd64", "plugin.dll"},
|
}
|
||||||
|
|
||||||
|
// checkTargetSupported 在构建前拦下**已知不支持**的目标,给出可执行的报错。
|
||||||
|
//
|
||||||
|
// 为什么要有它:插件运行在 homed 的进程里,所以目标平台必须是 homed 能跑的。
|
||||||
|
// homed 已放弃 Windows 原生(原因:插件依赖 fd 继承与统一共享内存区段内偏移,
|
||||||
|
// Windows 句柄模型无法表达),却还去构建 windows 插件,结果是死在一句
|
||||||
|
// 「undefined: attachUnifiedShm」——看起来像代码 bug,实际是平台策略。
|
||||||
|
// 这里换成明确的结论,并且**不静默跳过**:静默跳过会让人以为产出的包里包含 windows。
|
||||||
|
func checkTargetSupported(target string) error {
|
||||||
|
if strings.HasPrefix(target, "windows/") {
|
||||||
|
return fmt.Errorf("不支持 windows 插件目标:插件运行在 homed 内," +
|
||||||
|
"而 homed 已放弃 Windows 原生支持(插件体系依赖 fd 继承与统一共享内存区" +
|
||||||
|
"段内偏移解引用,Windows 句柄模型无法表达)。Windows 请用 WSL2——" +
|
||||||
|
"它就是 linux/amd64,用 --target linux/amd64 即可")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
|
func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
|
||||||
@ -120,39 +171,49 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
|
|||||||
buildDir := "build"
|
buildDir := "build"
|
||||||
os.MkdirAll(buildDir, 0755)
|
os.MkdirAll(buildDir, 0755)
|
||||||
|
|
||||||
// Auto-generate C ABI bridge for non-Windows
|
runtimeCleanup, err := generateProcRuntime()
|
||||||
bridgeCleanup := generateBridge("")
|
if err != nil {
|
||||||
defer bridgeCleanup()
|
fmt.Printf(" error: %v\n", err)
|
||||||
|
buildFailed = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer runtimeCleanup()
|
||||||
|
|
||||||
thirdpartCleanup := linkThirdpart(plg, "linux/amd64")
|
thirdpartCleanup := linkThirdpart(plg, "linux/amd64")
|
||||||
defer thirdpartCleanup()
|
defer thirdpartCleanup()
|
||||||
|
|
||||||
var binaries []binEntry
|
var binaries []binEntry
|
||||||
|
|
||||||
for _, bt := range allBundleTargets {
|
for _, bt := range allBundleTargets {
|
||||||
|
if err := checkTargetSupported(bt.target); err != nil {
|
||||||
|
fmt.Printf(" error: %v\n", err)
|
||||||
|
buildFailed = true
|
||||||
|
return
|
||||||
|
}
|
||||||
cfg, errMsg := resolveBuild(bt.target)
|
cfg, errMsg := resolveBuild(bt.target)
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
fmt.Printf(" error: %s\n", errMsg)
|
fmt.Printf(" error: %s\n", errMsg)
|
||||||
|
buildFailed = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
outPath := filepath.Join(buildDir, cfg.entryFile)
|
// 每平台产物落到独立路径,避免相互覆盖
|
||||||
|
outName := fmt.Sprintf("%s_%s_%s", cfg.entryFile, cfg.goos, cfg.goarch)
|
||||||
|
outPath := filepath.Join(buildDir, outName)
|
||||||
|
|
||||||
cmd := exec.Command("go", "build", "-buildmode=c-shared", "-o", outPath)
|
// 零 cgo:跨平台交叉编译不需目标平台 C 工具链
|
||||||
|
cmd := exec.Command("go", "build", "-trimpath", "-o", outPath)
|
||||||
cmd.Env = os.Environ()
|
cmd.Env = os.Environ()
|
||||||
cmd.Env = append(cmd.Env, "GOOS="+cfg.goos, "GOARCH="+cfg.goarch, "CGO_ENABLED=1")
|
cmd.Env = append(cmd.Env, "GOOS="+cfg.goos, "GOARCH="+cfg.goarch, "CGO_ENABLED=0")
|
||||||
|
|
||||||
if cfg.goos == "windows" {
|
|
||||||
cc := detectWindowsCC()
|
|
||||||
if cc != "" {
|
|
||||||
cmd.Env = append(cmd.Env, "CC="+cc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
fmt.Printf(" compiling %s/%s (-buildmode=c-shared)...\n", cfg.goos, cfg.goarch)
|
|
||||||
|
fmt.Printf(" compiling %s/%s (子进程模式,CGO_ENABLED=0)...\n", cfg.goos, cfg.goarch)
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
|
// 单平台失败即整包失败:bundle 少一个平台就是个坏包,
|
||||||
|
// 却仍会生成 .hmap 让人以为打包成功。
|
||||||
fmt.Printf(" error: build %s/%s: %v\n", cfg.goos, cfg.goarch, err)
|
fmt.Printf(" error: build %s/%s: %v\n", cfg.goos, cfg.goarch, err)
|
||||||
|
buildFailed = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
binaries = append(binaries, binEntry{src: outPath, zip: bt.entry})
|
binaries = append(binaries, binEntry{src: outPath, zip: bt.entry})
|
||||||
@ -168,7 +229,7 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
|
|||||||
for p := range platforms {
|
for p := range platforms {
|
||||||
plats = append(plats, p)
|
plats = append(plats, p)
|
||||||
}
|
}
|
||||||
writePluginJSON(plg, plats, "plugin.so")
|
writePluginJSON(plg, plats, procEntryFile)
|
||||||
|
|
||||||
// package single .hmap with correctly named entries
|
// package single .hmap with correctly named entries
|
||||||
hmapPath := filepath.Join(outDir, fmt.Sprintf("%s_bundle.hmap", toSnake(plg.NameEn)))
|
hmapPath := filepath.Join(outDir, fmt.Sprintf("%s_bundle.hmap", toSnake(plg.NameEn)))
|
||||||
@ -176,7 +237,10 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
|
|||||||
fmt.Printf(" packaged %s\n", filepath.Base(hmapPath))
|
fmt.Printf(" packaged %s\n", filepath.Base(hmapPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PlgConfig) IsLua() bool { return p.Entry == "main.lua" }
|
// IsLua 判断是否为 Lua 插件(走解释器,不经过 Go 编译)。
|
||||||
|
//
|
||||||
|
// 这是 entry 字段唯一仍在使用的用途:Go 插件不再看 entry 值,一律产出 plugin.bin。
|
||||||
|
func (p *PlgConfig) IsLua() bool { return p.Entry == luaEntryFile }
|
||||||
|
|
||||||
func readPlgJSON(path string) (*PlgConfig, error) {
|
func readPlgJSON(path string) (*PlgConfig, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
@ -221,6 +285,10 @@ func writePluginJSON(plg *PlgConfig, platforms []string, entry string) {
|
|||||||
if len(plg.Tags) > 0 {
|
if len(plg.Tags) > 0 {
|
||||||
m["tags"] = plg.Tags
|
m["tags"] = plg.Tags
|
||||||
}
|
}
|
||||||
|
// 记录「用哪版 SDK 编的」:插件产物与内核协议绑定,出问题时这是第一个要看的字段。
|
||||||
|
if plg.ResolvedSDK != "" {
|
||||||
|
m["sdk"] = plg.ResolvedSDK
|
||||||
|
}
|
||||||
data, _ := json.MarshalIndent(m, "", " ")
|
data, _ := json.MarshalIndent(m, "", " ")
|
||||||
os.WriteFile("plugin.json", data, 0644)
|
os.WriteFile("plugin.json", data, 0644)
|
||||||
}
|
}
|
||||||
@ -228,9 +296,14 @@ func writePluginJSON(plg *PlgConfig, platforms []string, entry string) {
|
|||||||
type buildConfig struct {
|
type buildConfig struct {
|
||||||
goos string
|
goos string
|
||||||
goarch string
|
goarch string
|
||||||
entryFile string // "plugin.so" or "plugin.dll"
|
entryFile string // 一律为 plugin.bin(进程边界即 ABI 边界,无平台特有扩展名)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveBuild 解析目标平台。
|
||||||
|
//
|
||||||
|
// 全平台统一产出 plugin.bin:子进程模式下不存在 .so/.dylib/.dll 的区分,
|
||||||
|
// 因为进程边界本身就是 ABI 边界——这正是三套独立 ABI 实现收敛为
|
||||||
|
// 单一 RPC 实现的直接后果(§9.2:Windows 不再是能力退化的第三套实现)。
|
||||||
func resolveBuild(target string) (*buildConfig, string) {
|
func resolveBuild(target string) (*buildConfig, string) {
|
||||||
if target == "lua" || target == "" {
|
if target == "lua" || target == "" {
|
||||||
return nil, "lua"
|
return nil, "lua"
|
||||||
@ -245,14 +318,8 @@ func resolveBuild(target string) (*buildConfig, string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch goos {
|
switch goos {
|
||||||
case "linux":
|
case "linux", "darwin", "freebsd", "windows":
|
||||||
return &buildConfig{goos: goos, goarch: goarch, entryFile: "plugin.so"}, ""
|
return &buildConfig{goos: goos, goarch: goarch, entryFile: procEntryFile}, ""
|
||||||
case "darwin":
|
|
||||||
return &buildConfig{goos: goos, goarch: goarch, entryFile: "plugin.dylib"}, ""
|
|
||||||
case "freebsd":
|
|
||||||
return &buildConfig{goos: goos, goarch: goarch, entryFile: "plugin.so"}, ""
|
|
||||||
case "windows":
|
|
||||||
return &buildConfig{goos: goos, goarch: goarch, entryFile: "plugin.dll"}, ""
|
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Sprintf("unsupported OS %q", goos)
|
return nil, fmt.Sprintf("unsupported OS %q", goos)
|
||||||
}
|
}
|
||||||
@ -323,7 +390,28 @@ func ensureGoMod(plg *PlgConfig, sdkPath string) string {
|
|||||||
}
|
}
|
||||||
keep = append(keep, line)
|
keep = append(keep, line)
|
||||||
}
|
}
|
||||||
if alreadyExists {
|
// 同步 require 版本:replace 指向 1.2.1 而 require 还写 1.2.0 是自相矛盾的
|
||||||
|
// —— 有人删掉 replace 就会静默退回旧版本去编(`go list -m` 报的也是假版本)。
|
||||||
|
// 以本次真正选中的版本为准改写 require 行。
|
||||||
|
requireChanged := false
|
||||||
|
if v := normalizeSDKVersion(plg.ResolvedSDK); v != "" {
|
||||||
|
want := "require " + sdkModule + " v" + v
|
||||||
|
for i, line := range keep {
|
||||||
|
t := strings.TrimSpace(line)
|
||||||
|
if !strings.HasPrefix(t, "require ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.Fields(t)
|
||||||
|
if len(parts) >= 3 && parts[1] == sdkModule {
|
||||||
|
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
|
||||||
|
if t != want {
|
||||||
|
keep[i] = indent + want
|
||||||
|
requireChanged = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if alreadyExists && !requireChanged {
|
||||||
return sdkModule
|
return sdkModule
|
||||||
}
|
}
|
||||||
keep = append(keep, replaceLine, "")
|
keep = append(keep, replaceLine, "")
|
||||||
@ -333,6 +421,123 @@ func ensureGoMod(plg *PlgConfig, sdkPath string) string {
|
|||||||
return sdkModule
|
return sdkModule
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureSDKResolvable 保证 SDK 模块在编译前可解析。
|
||||||
|
//
|
||||||
|
// 为何需要这个函数:gitcode 的模块不在 proxy.golang.org 上。只要 go.mod
|
||||||
|
// 里的 SDK 靠 require 版本号解析,而本地又没 go.sum 条目,go build 就报
|
||||||
|
// "missing go.sum entry";而原来那句 `go mod download <mod>` 会去公共 proxy
|
||||||
|
// 拉一个永远拉不到的条目,超时后只打一行 warn 就继继编译,紧接着死在
|
||||||
|
// 同一个错误上——新用户拿到的是两段无关的报错。
|
||||||
|
//
|
||||||
|
// 三级策略,按代价递增:
|
||||||
|
// 1. go.mod 已有指向本地目录的 replace —— 什么都不用做(replace 到目录时
|
||||||
|
// go 不需要也不校验 go.sum)。
|
||||||
|
// 2. 能定位到本机 SDK 源码 —— 写入 replace。这是存量项目(go.mod 旧、
|
||||||
|
// 无 replace)的救场路径。
|
||||||
|
// 3. 都不行 —— 跑 `go mod tidy`(带 -mod=mod)让它自己去试,失败则给
|
||||||
|
// 可操作的提示而不是让用户去猜。
|
||||||
|
func ensureSDKResolvable(plg *PlgConfig, sdkModule, sdkPath string) {
|
||||||
|
data, err := os.ReadFile("go.mod")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 策略 1:已有指向本地目录的 replace。
|
||||||
|
// replace 目标带 / 或 . 开头的才是路径;指向另一个模块的 replace 不算。
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if !strings.HasPrefix(line, "replace ") || !strings.Contains(line, sdkModule) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) < 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
target := parts[3]
|
||||||
|
if strings.HasPrefix(target, ".") || strings.HasPrefix(target, "/") ||
|
||||||
|
strings.Contains(target, ":/") || strings.Contains(target, ":\\") {
|
||||||
|
return // 已指向本地目录,无需 go.sum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 策略 2:能定位到本机 SDK 就写 replace。
|
||||||
|
// resolveSDKPath 失败会 os.Exit,所以只在能确定拿到路径时调用它背后的探测。
|
||||||
|
if root := findLocalSDK(sdkPath); root != "" {
|
||||||
|
if appendGoModReplace(sdkModule, root) {
|
||||||
|
fmt.Printf(" SDK 指向本机源码(已写入 go.mod replace):%s\n", root)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 策略 3:交给 go mod tidy。
|
||||||
|
if _, err := os.Stat("go.sum"); err == nil {
|
||||||
|
return // 已有 go.sum,不插手
|
||||||
|
}
|
||||||
|
fmt.Println(" 解析 SDK 依赖(go mod tidy)...")
|
||||||
|
tidy := exec.Command("go", "mod", "tidy")
|
||||||
|
tidy.Env = append(os.Environ(), "GOFLAGS=-mod=mod")
|
||||||
|
if out, err := tidy.CombinedOutput(); err != nil {
|
||||||
|
fmt.Printf(" warn: go mod tidy 失败:%v\n", err)
|
||||||
|
if len(out) > 0 {
|
||||||
|
fmt.Printf(" %s\n", strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
fmt.Printf(" 提示:%s 不在公共 proxy 上。用以下任一方式指向本机 SDK:\n", sdkModule)
|
||||||
|
fmt.Printf(" hmapdev sdk install latest # 装一份到 ~/.homeagent/hmapdev/sdk\n")
|
||||||
|
fmt.Printf(" hmapdev build --sdk-path <路径> # 或直接指定源码目录\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findLocalSDK 探测本机 SDK 源码根目录,找不到返回空串。
|
||||||
|
//
|
||||||
|
// 与 resolveSDKPath 的区别:后者找不到就 os.Exit,适合“必须有”的调用点;
|
||||||
|
// 这里是“有则更好”的探测,不能把构建搞挂。
|
||||||
|
func findLocalSDK(sdkPath string) string {
|
||||||
|
candidates := []string{}
|
||||||
|
if sdkPath != "" {
|
||||||
|
if abs, err := filepath.Abs(sdkPath); err == nil {
|
||||||
|
candidates = append(candidates, abs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// hmapdev 自身所在位置往上三级(tools/hmapdev/hmapdev → SDK 根)
|
||||||
|
if self, err := os.Executable(); err == nil {
|
||||||
|
candidates = append(candidates, filepath.Dir(filepath.Dir(filepath.Dir(self))))
|
||||||
|
}
|
||||||
|
// hmapdev sdk use 选定的版本(复用 sdkStore(),含改名前的旧目录回退)
|
||||||
|
store := sdkStore()
|
||||||
|
if store != "" {
|
||||||
|
if d, err := os.ReadFile(filepath.Join(store, "current")); err == nil {
|
||||||
|
if ver := strings.TrimSpace(string(d)); ver != "" {
|
||||||
|
candidates = append(candidates, filepath.Join(store, ver))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range candidates {
|
||||||
|
if c == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(c, "sdk", "plugin.go")); err == nil {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendGoModReplace 向 go.mod 追加一条 replace,成功返回 true。
|
||||||
|
func appendGoModReplace(module, localPath string) bool {
|
||||||
|
data, err := os.ReadFile("go.mod")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
abs, err := filepath.Abs(localPath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
abs = strings.ReplaceAll(abs, "\\", "/")
|
||||||
|
s := strings.TrimRight(string(data), "\r\n")
|
||||||
|
s += fmt.Sprintf("\n\nreplace %s => %s\n", module, abs)
|
||||||
|
return os.WriteFile("go.mod", []byte(s), 0644) == nil
|
||||||
|
}
|
||||||
|
|
||||||
func resolveSDKPath(sdkPath string) string {
|
func resolveSDKPath(sdkPath string) string {
|
||||||
if sdkPath != "" {
|
if sdkPath != "" {
|
||||||
abs, _ := filepath.Abs(sdkPath)
|
abs, _ := filepath.Abs(sdkPath)
|
||||||
@ -342,7 +547,7 @@ func resolveSDKPath(sdkPath string) string {
|
|||||||
fmt.Printf("error: --sdk-path %q not a valid SDK\n", sdkPath)
|
fmt.Printf("error: --sdk-path %q not a valid SDK\n", sdkPath)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
// Detect from plugindev's own location (internal dev)
|
// Detect from hmapdev's own location (internal dev)
|
||||||
self, err := os.Executable()
|
self, err := os.Executable()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
cand := filepath.Dir(filepath.Dir(filepath.Dir(self)))
|
cand := filepath.Dir(filepath.Dir(filepath.Dir(self)))
|
||||||
@ -350,14 +555,8 @@ func resolveSDKPath(sdkPath string) string {
|
|||||||
return cand
|
return cand
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Active SDK via plugindev sdk use
|
// Active SDK via hmapdev sdk use
|
||||||
store := os.Getenv("HOMEAGENT_SDK_DIR")
|
store := sdkStore()
|
||||||
if store == "" {
|
|
||||||
home, _ := os.UserHomeDir()
|
|
||||||
if home != "" {
|
|
||||||
store = filepath.Join(home, ".homeagent", "plugindev", "sdk")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if store != "" {
|
if store != "" {
|
||||||
if d, err := os.ReadFile(filepath.Join(store, "current")); err == nil {
|
if d, err := os.ReadFile(filepath.Join(store, "current")); err == nil {
|
||||||
ver := strings.TrimSpace(string(d))
|
ver := strings.TrimSpace(string(d))
|
||||||
@ -369,7 +568,7 @@ func resolveSDKPath(sdkPath string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Printf("error: cannot locate SDK. Use --sdk-path or 'plugindev sdk use'\n")
|
fmt.Printf("error: cannot locate SDK. Use --sdk-path or 'hmapdev sdk use'\n")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@ -403,7 +602,7 @@ func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve build config
|
// Resolve build config(全平台统一产出 plugin.bin)
|
||||||
cfg, errMsg := resolveBuild(target)
|
cfg, errMsg := resolveBuild(target)
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
fmt.Printf(" error: %s\n", errMsg)
|
fmt.Printf(" error: %s\n", errMsg)
|
||||||
@ -414,9 +613,19 @@ func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
|
|||||||
os.MkdirAll(buildDir, 0755)
|
os.MkdirAll(buildDir, 0755)
|
||||||
outPath := filepath.Join(buildDir, cfg.entryFile)
|
outPath := filepath.Join(buildDir, cfg.entryFile)
|
||||||
|
|
||||||
// Auto-generate C ABI bridge (all platforms use c-shared)
|
runtimeCleanup, err := generateProcRuntime()
|
||||||
bridgeCleanup := generateBridge(cfg.goos)
|
if err != nil {
|
||||||
defer bridgeCleanup()
|
fmt.Printf(" error: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer runtimeCleanup()
|
||||||
|
|
||||||
|
// 已知未实现的目标在编译前拦下,给可执行的报错(见 checkTargetSupported)。
|
||||||
|
if err := checkTargetSupported(target); err != nil {
|
||||||
|
fmt.Printf(" error: %v\n", err)
|
||||||
|
buildFailed = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-link thirdpart/ contents + source_dirs + replace targets
|
// Auto-link thirdpart/ contents + source_dirs + replace targets
|
||||||
thirdpartCleanup := linkThirdpart(plg, target)
|
thirdpartCleanup := linkThirdpart(plg, target)
|
||||||
@ -425,30 +634,18 @@ func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
|
|||||||
// Write plugin.json with the correct entry for this target
|
// Write plugin.json with the correct entry for this target
|
||||||
writePluginJSON(plg, nil, cfg.entryFile)
|
writePluginJSON(plg, nil, cfg.entryFile)
|
||||||
|
|
||||||
cmd := exec.Command("go", "build", "-buildmode=c-shared", "-o", outPath)
|
// 普通 go build + 零 cgo:交叉编译不再需要目标平台的 C 工具链
|
||||||
|
// (旧路径靠 detectWindowsCC 找 MinGW,现在整个问题消失)。
|
||||||
|
cmd := exec.Command("go", "build", "-trimpath", "-o", outPath)
|
||||||
cmd.Env = os.Environ()
|
cmd.Env = os.Environ()
|
||||||
cmd.Env = append(cmd.Env, "GOOS="+cfg.goos, "GOARCH="+cfg.goarch, "CGO_ENABLED=1")
|
cmd.Env = append(cmd.Env, "GOOS="+cfg.goos, "GOARCH="+cfg.goarch, "CGO_ENABLED=0")
|
||||||
|
|
||||||
// Auto-detect MinGW gcc on Windows
|
|
||||||
if cfg.goos == "windows" {
|
|
||||||
cc := detectWindowsCC()
|
|
||||||
if cc != "" {
|
|
||||||
cmd.Env = append(cmd.Env, "CC="+cc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
// DEBUG: list files before building
|
fmt.Printf(" compiling %s/%s (子进程模式,CGO_ENABLED=0)...\n", cfg.goos, cfg.goarch)
|
||||||
entries, _ := os.ReadDir(".")
|
|
||||||
for _, e := range entries {
|
|
||||||
fmt.Printf(" [DEBUG] file: %s\n", e.Name())
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf(" compiling %s/%s (-buildmode=c-shared)...\n", cfg.goos, cfg.goarch)
|
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
fmt.Printf(" error: build %s/%s: %v\n", cfg.goos, cfg.goarch, err)
|
fmt.Printf(" error: build %s/%s: %v\n", cfg.goos, cfg.goarch, err)
|
||||||
|
buildFailed = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -465,8 +662,8 @@ func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type binEntry struct {
|
type binEntry struct {
|
||||||
src string // 磁盘路径,如 build/plugin.so
|
src string // 磁盘路径,如 build/plugin.bin
|
||||||
zip string // zip 中条目名,如 plugin.so
|
zip string // zip 中条目名,如 plugin.bin.linux.amd64
|
||||||
}
|
}
|
||||||
|
|
||||||
// createBundleHmap 创建包含多平台二进制的 bundle .hmap 文件。
|
// createBundleHmap 创建包含多平台二进制的 bundle .hmap 文件。
|
||||||
@ -566,72 +763,8 @@ func toSnake(s string) string {
|
|||||||
return strings.ToLower(strings.ReplaceAll(s, " ", "_"))
|
return strings.ToLower(strings.ReplaceAll(s, " ", "_"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectWindowsCC looks for a MinGW-w64 gcc on Windows for c-shared builds.
|
|
||||||
func detectWindowsCC() string {
|
|
||||||
// Check CC from environment first
|
|
||||||
if cc := os.Getenv("CC"); cc != "" {
|
|
||||||
if _, err := exec.LookPath(cc); err == nil {
|
|
||||||
return cc
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Check common MinGW install paths
|
|
||||||
candidates := []string{
|
|
||||||
"C:\\mingw64\\bin\\gcc.exe",
|
|
||||||
"C:\\MinGW\\bin\\gcc.exe",
|
|
||||||
"C:\\msys64\\mingw64\\bin\\gcc.exe",
|
|
||||||
"C:\\Users\\21989\\AppData\\Local\\Temp\\mingw64\\mingw64\\bin\\gcc.exe",
|
|
||||||
}
|
|
||||||
// Also search PATH for gcc
|
|
||||||
if path, err := exec.LookPath("gcc"); err == nil {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
for _, c := range candidates {
|
|
||||||
if _, err := os.Stat(c); err == nil {
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// stripIncludeGuard strips preprocessor guards and C++ comments from a C header,
|
// stripIncludeGuard strips preprocessor guards and C++ comments from a C header,
|
||||||
// since these can confuse cgo's type resolution.
|
// since these can confuse cgo's type resolution.
|
||||||
// generateBridge generates the C ABI bridge files for non-Lua builds.
|
|
||||||
// Returns a cleanup function to remove generated files.
|
|
||||||
func generateBridge(goos string) func() {
|
|
||||||
const bridgeFile = "z_bridge_gen.go"
|
|
||||||
const cEntryFile = "z_entry.c"
|
|
||||||
os.Remove(bridgeFile)
|
|
||||||
os.Remove(cEntryFile)
|
|
||||||
|
|
||||||
var files []string
|
|
||||||
|
|
||||||
if goos == "windows" {
|
|
||||||
if err := os.WriteFile(bridgeFile, []byte(tmplBridge), 0644); err != nil {
|
|
||||||
fmt.Printf(" error: write bridge: %v\n", err)
|
|
||||||
return func() {}
|
|
||||||
}
|
|
||||||
files = append(files, bridgeFile)
|
|
||||||
} else {
|
|
||||||
if err := os.WriteFile(bridgeFile, []byte(tmplLinuxBridge), 0644); err != nil {
|
|
||||||
fmt.Printf(" error: write bridge: %v\n", err)
|
|
||||||
return func() {}
|
|
||||||
}
|
|
||||||
files = append(files, bridgeFile)
|
|
||||||
// Write C entry point file
|
|
||||||
if err := os.WriteFile(cEntryFile, []byte(tmplPluginInitC), 0644); err != nil {
|
|
||||||
fmt.Printf(" error: write C entry: %v\n", err)
|
|
||||||
return func() {}
|
|
||||||
}
|
|
||||||
files = append(files, cEntryFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
return func() {
|
|
||||||
for _, f := range files {
|
|
||||||
os.Remove(f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// linkThirdpart scans thirdpart/, source_dirs from plg.json, and replace target dirs
|
// linkThirdpart scans thirdpart/, source_dirs from plg.json, and replace target dirs
|
||||||
// for source files, generating auto-import stubs. Returns cleanup function.
|
// for source files, generating auto-import stubs. Returns cleanup function.
|
||||||
func linkThirdpart(plg *PlgConfig, target string) func() {
|
func linkThirdpart(plg *PlgConfig, target string) func() {
|
||||||
@ -7,12 +7,12 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/JianFeeeee/homeagent-sdk/tools/plugindev/yaegi"
|
"gitcode.com/JianFeeeee/homeagent-sdk/tools/hmapdev/yaegi"
|
||||||
)
|
)
|
||||||
|
|
||||||
// tmplLuaDebug is the temporary Lua debug script template
|
// tmplLuaDebug is the temporary Lua debug script template
|
||||||
const tmplLuaDebug = `-- HomeAgent Lua Plugin Debug
|
const tmplLuaDebug = `-- HomeAgent Lua Plugin Debug
|
||||||
-- Generated by plugindev debug --lua
|
-- Generated by hmapdev debug --lua
|
||||||
sdk = require("sdk")
|
sdk = require("sdk")
|
||||||
local ok, plugin = pcall(dofile, "main.lua")
|
local ok, plugin = pcall(dofile, "main.lua")
|
||||||
if not ok then
|
if not ok then
|
||||||
@ -115,7 +115,7 @@ func debugLua(dir, sdkPath, luaPath string) {
|
|||||||
luaBin, err := exec.LookPath("lua")
|
luaBin, err := exec.LookPath("lua")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("error: lua interpreter not found in PATH")
|
fmt.Println("error: lua interpreter not found in PATH")
|
||||||
fmt.Println(" install Lua 5.1+ or use plugindev build to compile your plugin")
|
fmt.Println(" install Lua 5.1+ or use hmapdev build to compile your plugin")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -7,8 +7,6 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/homeagent-sdk/meta"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (p *PlgConfig) ReplacesToSlice() []string {
|
func (p *PlgConfig) ReplacesToSlice() []string {
|
||||||
@ -36,6 +34,18 @@ type PlgConfig struct {
|
|||||||
GoVersion string `json:"go_version,omitempty"`
|
GoVersion string `json:"go_version,omitempty"`
|
||||||
Replaces map[string]string `json:"replaces,omitempty"`
|
Replaces map[string]string `json:"replaces,omitempty"`
|
||||||
SourceDirs []string `json:"source_dirs,omitempty"`
|
SourceDirs []string `json:"source_dirs,omitempty"`
|
||||||
|
|
||||||
|
// SDK 声明本插件针对的 SDK **接口版本**(中版本或完整版本,如 "1.2" / "1.2.1")。
|
||||||
|
//
|
||||||
|
// 为何需要:工具链存储里可能装有多个 SDK 版本,而插件产物与内核是协议绑定的——
|
||||||
|
// 不给声明就只能猜(旧行为是直接用 current:谁改过 current 就拿谁的版本编,
|
||||||
|
// 出错时表现为莫名其妙的编译错误)。写中版本表示「只要 1.2 这条接口线,
|
||||||
|
// 补丁由工具链挑最新」(patch 只含工具链/打包修复,接口不变,见 README 版本语义)。
|
||||||
|
SDK string `json:"sdk,omitempty"`
|
||||||
|
|
||||||
|
// ResolvedSDK 是本次构建实际选中的 SDK 版本(build 按 SDK 声明解析后回填),
|
||||||
|
// 只写进产物里的 plugin.json,便于事后追溯「这个 .hmap 是哪版 SDK 编的」。
|
||||||
|
ResolvedSDK string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TargetList parses the Targets string into a slice.
|
// TargetList parses the Targets string into a slice.
|
||||||
@ -62,14 +72,18 @@ type TemplateData struct {
|
|||||||
SDKModule string
|
SDKModule string
|
||||||
SDKVersion string
|
SDKVersion string
|
||||||
|
|
||||||
// C ABI
|
// SDKLocalPath 是本机 SDK 源码绝对路径,写入生成的 go.mod 作为 replace 目标。
|
||||||
CABIVersion int
|
//
|
||||||
CABIHeader string
|
// 为何必须写:gitcode 的模块不在 proxy.golang.org 上,只 require 一个
|
||||||
|
// 版本号的 go.mod 配上缺失的 go.sum,新用户第一次 `hmapdev build`
|
||||||
|
// 必定死在 "missing go.sum entry",而 `go mod tidy` 又会去公共 proxy 拉
|
||||||
|
// 一个不存在的条目。有了本地 replace,go 完全不需要 go.sum 条目。
|
||||||
|
SDKLocalPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func cmdInit(args []string) {
|
func cmdInit(args []string) {
|
||||||
if len(args) < 1 {
|
if len(args) < 1 {
|
||||||
fmt.Println("Usage: plugindev init <name> [--lua] [--type remotedevice]")
|
fmt.Println("Usage: hmapdev init <name> [--lua] [--type remotedevice]")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,7 +129,12 @@ func cmdInit(args []string) {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
entry := "plugin.so"
|
// Go 插件统一产出 plugin.bin(v1.0.0 子进程模式)。
|
||||||
|
//
|
||||||
|
// 此前这里写 "plugin.so",scaffold 出来的 plg.json 就带着一个已退场的
|
||||||
|
// entry 值,新手跟着模板走会误以为自己在做 C ABI 插件。
|
||||||
|
// build 实际不看这个值(只用它区分 Lua),但模板不应误导。
|
||||||
|
entry := "plugin.bin"
|
||||||
var targets string
|
var targets string
|
||||||
if isLua {
|
if isLua {
|
||||||
entry = "main.lua"
|
entry = "main.lua"
|
||||||
@ -139,20 +158,22 @@ func cmdInit(args []string) {
|
|||||||
Tags: []string{name},
|
Tags: []string{name},
|
||||||
Targets: targets,
|
Targets: targets,
|
||||||
},
|
},
|
||||||
IsLua: isLua,
|
IsLua: isLua,
|
||||||
CABIVersion: meta.CABINum,
|
|
||||||
CABIHeader: tmplCABIHeader,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect SDK info for Go plugin go.mod.
|
// Detect SDK info for Go plugin go.mod.
|
||||||
// 生成的 go.mod 只 require SDK 线上模块版本,不写本地路径 replace;
|
// 生成的 go.mod 除 require 外还写一条指向本机 SDK 的 replace:
|
||||||
// 本地调试请用 `plugindev build --sdk-path <path>` 或手动加 replace。
|
// 否则 scaffold 出来的项目第一次 build 必定失败(详见 SDKLocalPath 注释)。
|
||||||
if !isLua {
|
if !isLua {
|
||||||
sdkMod, goVer, _, sdkVer := detectSDKInfo()
|
sdkMod, goVer, sdkRoot, sdkVer := detectSDKInfo()
|
||||||
data.ModulePath = name
|
data.ModulePath = name
|
||||||
data.GoVersion = goVer
|
data.GoVersion = goVer
|
||||||
data.SDKModule = sdkMod
|
data.SDKModule = sdkMod
|
||||||
data.SDKVersion = "v" + sdkVer
|
data.SDKVersion = "v" + sdkVer
|
||||||
|
data.SDKLocalPath = strings.ReplaceAll(sdkRoot, "\\", "/")
|
||||||
|
// 声明**完整版本号**:SDK 版本跟随内核中版本、patch 位恒为 .0,
|
||||||
|
// 一条内核线只对应一个 SDK 版本(build 时按此解析,见 ResolveSDKForProject)。
|
||||||
|
data.Plg.SDK = normalizeSDKVersion(sdkVer)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
@ -186,7 +207,7 @@ func cmdInit(args []string) {
|
|||||||
if isLua {
|
if isLua {
|
||||||
fmt.Printf(" cd %s && lua main.lua (standalone test)\n", dir)
|
fmt.Printf(" cd %s && lua main.lua (standalone test)\n", dir)
|
||||||
}
|
}
|
||||||
fmt.Printf(" cd %s && plugindev build\n", dir)
|
fmt.Printf(" cd %s && hmapdev build\n", dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectSDKInfo reads the HomeAgent SDK's go.mod and meta to get module path, go version, and SDK version.
|
// detectSDKInfo reads the HomeAgent SDK's go.mod and meta to get module path, go version, and SDK version.
|
||||||
@ -14,7 +14,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const sdkDirName = "plugindev/sdk"
|
const sdkDirName = "hmapdev/sdk"
|
||||||
|
|
||||||
|
// legacySDKDirName 是改名前的存储目录。工具链在 1.2.0 从 plugindev 改名 hmapdev;
|
||||||
|
// 已装过旧版的机器上 SDK 仍在旧路径,直接换名会让它找不到已装 SDK
|
||||||
|
// (表现为「没有活动版本」)。新目录不存在而旧目录存在时沿用旧目录。
|
||||||
|
const legacySDKDirName = "plugindev/sdk"
|
||||||
|
|
||||||
// sdkStore returns the root directory for stored SDK versions.
|
// sdkStore returns the root directory for stored SDK versions.
|
||||||
func sdkStore() string {
|
func sdkStore() string {
|
||||||
@ -26,7 +31,15 @@ func sdkStore() string {
|
|||||||
fmt.Printf("error: cannot determine home directory: %v\n", err)
|
fmt.Printf("error: cannot determine home directory: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
return filepath.Join(home, ".homeagent", sdkDirName)
|
dir := filepath.Join(home, ".homeagent", sdkDirName)
|
||||||
|
if _, err := os.Stat(dir); err != nil {
|
||||||
|
if legacy := filepath.Join(home, ".homeagent", legacySDKDirName); legacy != "" {
|
||||||
|
if _, err := os.Stat(legacy); err == nil {
|
||||||
|
return legacy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dir
|
||||||
}
|
}
|
||||||
|
|
||||||
func sdkCurrentDir() string {
|
func sdkCurrentDir() string {
|
||||||
@ -50,13 +63,13 @@ func cmdSDK(args []string) {
|
|||||||
cmdSDKList()
|
cmdSDKList()
|
||||||
case "install":
|
case "install":
|
||||||
if len(args) < 2 {
|
if len(args) < 2 {
|
||||||
fmt.Println("Usage: plugindev sdk install <version>")
|
fmt.Println("Usage: hmapdev sdk install <version>")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
cmdSDKInstall(args[1])
|
cmdSDKInstall(args[1])
|
||||||
case "use":
|
case "use":
|
||||||
if len(args) < 2 {
|
if len(args) < 2 {
|
||||||
fmt.Println("Usage: plugindev sdk use <version>")
|
fmt.Println("Usage: hmapdev sdk use <version>")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
cmdSDKUse(args[1])
|
cmdSDKUse(args[1])
|
||||||
@ -72,7 +85,7 @@ func cmdSDK(args []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sdkHelp() {
|
func sdkHelp() {
|
||||||
fmt.Print(`Usage: plugindev sdk <command>
|
fmt.Print(`Usage: hmapdev sdk <command>
|
||||||
|
|
||||||
Manage installed HomeAgent SDK versions.
|
Manage installed HomeAgent SDK versions.
|
||||||
|
|
||||||
@ -85,9 +98,9 @@ Commands:
|
|||||||
latest Show the latest available version from remote
|
latest Show the latest available version from remote
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
plugindev sdk install v0.7.1
|
hmapdev sdk install v0.7.1
|
||||||
plugindev sdk install latest
|
hmapdev sdk install latest
|
||||||
plugindev sdk use v0.7.1
|
hmapdev sdk use v0.7.1
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,7 +140,7 @@ func cmdSDKList() {
|
|||||||
fmt.Printf(" %s %s\n", mark, v)
|
fmt.Printf(" %s %s\n", mark, v)
|
||||||
}
|
}
|
||||||
if current == "" {
|
if current == "" {
|
||||||
fmt.Println("\nNo version active. Use 'plugindev sdk use <version>' to set one.")
|
fmt.Println("\nNo version active. Use 'hmapdev sdk use <version>' to set one.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -292,7 +305,7 @@ func cmdSDKUse(version string) {
|
|||||||
verDir := sdkVersionDir(version)
|
verDir := sdkVersionDir(version)
|
||||||
if _, err := os.Stat(verDir); os.IsNotExist(err) {
|
if _, err := os.Stat(verDir); os.IsNotExist(err) {
|
||||||
fmt.Printf("SDK version %s is not installed.\n", version)
|
fmt.Printf("SDK version %s is not installed.\n", version)
|
||||||
fmt.Printf("Install it first: plugindev sdk install %s\n", version)
|
fmt.Printf("Install it first: hmapdev sdk install %s\n", version)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
setCurrentVersion(store, version)
|
setCurrentVersion(store, version)
|
||||||
@ -305,7 +318,7 @@ func cmdSDKPath() {
|
|||||||
current := resolveCurrentVersion(store)
|
current := resolveCurrentVersion(store)
|
||||||
if current == "" {
|
if current == "" {
|
||||||
fmt.Println("No active SDK version set.")
|
fmt.Println("No active SDK version set.")
|
||||||
fmt.Println("Use 'plugindev sdk use <version>' to set one.")
|
fmt.Println("Use 'hmapdev sdk use <version>' to set one.")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
fmt.Println(sdkVersionDir(current))
|
fmt.Println(sdkVersionDir(current))
|
||||||
@ -440,21 +453,21 @@ func parseSemver(tag string) [3]int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// activeSDKRoot returns the path to the active SDK root.
|
// activeSDKRoot returns the path to the active SDK root.
|
||||||
// It replaces the old runtime.Caller(0) approach so plugindev can work
|
// It replaces the old runtime.Caller(0) approach so hmapdev can work
|
||||||
// independently of its own build location.
|
// independently of its own build location.
|
||||||
func activeSDKRoot() string {
|
func activeSDKRoot() string {
|
||||||
store := sdkStore()
|
store := sdkStore()
|
||||||
current := resolveCurrentVersion(store)
|
current := resolveCurrentVersion(store)
|
||||||
if current == "" {
|
if current == "" {
|
||||||
fmt.Printf("error: no active SDK version set\n")
|
fmt.Printf("error: no active SDK version set\n")
|
||||||
fmt.Printf(" Install one: plugindev sdk install latest\n")
|
fmt.Printf(" Install one: hmapdev sdk install latest\n")
|
||||||
fmt.Printf(" Or set one: plugindev sdk use <version>\n")
|
fmt.Printf(" Or set one: hmapdev sdk use <version>\n")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
root := sdkVersionDir(current)
|
root := sdkVersionDir(current)
|
||||||
if _, err := os.Stat(root); os.IsNotExist(err) {
|
if _, err := os.Stat(root); os.IsNotExist(err) {
|
||||||
fmt.Printf("error: active SDK version %s not found at %s\n", current, root)
|
fmt.Printf("error: active SDK version %s not found at %s\n", current, root)
|
||||||
fmt.Printf(" Reinstall: plugindev sdk install %s\n", current)
|
fmt.Printf(" Reinstall: hmapdev sdk install %s\n", current)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
return root
|
return root
|
||||||
@ -1,4 +1,4 @@
|
|||||||
module github.com/JianFeeeee/homeagent-sdk/tools/plugindev
|
module gitcode.com/JianFeeeee/homeagent-sdk/tools/hmapdev
|
||||||
|
|
||||||
go 1.21.0
|
go 1.21.0
|
||||||
|
|
||||||
80
tools/hmapdev/main.go
Normal file
80
tools/hmapdev/main.go
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"gitcode.com/JianFeeeee/homeagent-sdk/meta"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
help()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "init":
|
||||||
|
cmdInit(os.Args[2:])
|
||||||
|
case "build":
|
||||||
|
cmdBuild(os.Args[2:])
|
||||||
|
case "clean":
|
||||||
|
cmdClean(os.Args[2:])
|
||||||
|
case "debug":
|
||||||
|
cmdDebug(os.Args[2:])
|
||||||
|
case "sdk":
|
||||||
|
cmdSDK(os.Args[2:])
|
||||||
|
case "version", "-v", "--version":
|
||||||
|
printVersion()
|
||||||
|
default:
|
||||||
|
help()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// printVersion 输出工具链自身的版本身份。
|
||||||
|
//
|
||||||
|
// 为何必须有:此前工具链不报版本,而插件产物与内核是**协议绑定**的——
|
||||||
|
// 手里是哪一版工具链、能不能配当前内核,只能靠翻文件名或猜。
|
||||||
|
// 版本号来自 meta.Version(与 SDK 发布同源,由 -ldflags -X 注入);
|
||||||
|
// lnflags 未注入时它是源码里的默认值,此时提示它可能是开发构建。
|
||||||
|
func printVersion() {
|
||||||
|
printVersionTo(os.Stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// printVersionTo 把版本身份写到 w(抽出来是为了能被测试钉住)。
|
||||||
|
func printVersionTo(w io.Writer) {
|
||||||
|
fmt.Fprintf(w, "hmapdev %s\n", meta.Version)
|
||||||
|
fmt.Fprintf(w, " SDK 模块: %s\n", "gitcode.com/JianFeeeee/homeagent-sdk")
|
||||||
|
if meta.Commit != "" && meta.Commit != "unknown" {
|
||||||
|
fmt.Fprintf(w, " 构建提交: %s\n", meta.Commit)
|
||||||
|
}
|
||||||
|
if meta.BuildTime != "" && meta.BuildTime != "unknown" {
|
||||||
|
fmt.Fprintf(w, " 构建时间: %s\n", meta.BuildTime)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, " 构建用 Go: %s\n", runtime.Version())
|
||||||
|
fmt.Fprintf(w, " 可执行文件: %s\n", os.Args[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func help() {
|
||||||
|
fmt.Print(`HomeAgent Plugin Dev Tool
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
hmapdev version Print toolchain version
|
||||||
|
hmapdev init <name> Scaffold a new plugin project
|
||||||
|
hmapdev init <name> --lua Create Lua plugin
|
||||||
|
hmapdev init <name> --type remotedevice
|
||||||
|
Create C remote device adapter
|
||||||
|
hmapdev build [flags] Compile and package plugin
|
||||||
|
hmapdev clean Clean build/dist artifacts
|
||||||
|
hmapdev debug [dir] Interpret and debug plugin source
|
||||||
|
hmapdev sdk <command> Manage SDK versions
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
--outdir Output directory (default: dist)
|
||||||
|
--target Target OS/arch (e.g. linux/amd64), repeatable
|
||||||
|
--lua Create Lua plugin (for init)
|
||||||
|
--type Project type: "remotedevice" (for init)
|
||||||
|
-t Alias for --type
|
||||||
|
`)
|
||||||
|
}
|
||||||
57
tools/hmapdev/main_version_test.go
Normal file
57
tools/hmapdev/main_version_test.go
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitcode.com/JianFeeeee/homeagent-sdk/meta"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 工具链必须能报出自己的版本。
|
||||||
|
//
|
||||||
|
// 为什么值得钉住:插件产物与内核是协议绑定的,「手里是哪一版工具链」直接决定
|
||||||
|
// 产物能不能建链;此前既没有 version 子命令,`-ldflags -X meta.Version` 也因为
|
||||||
|
// meta 包没被链接而**静默无效**(表现为报不出任何版本)。
|
||||||
|
func TestPrintVersionReportsInjectedVersion(t *testing.T) {
|
||||||
|
origVersion, origCommit := meta.Version, meta.Commit
|
||||||
|
defer func() { meta.Version, meta.Commit = origVersion, origCommit }()
|
||||||
|
|
||||||
|
// 模拟 -ldflags 注入后的取值
|
||||||
|
meta.Version = "9.9.9"
|
||||||
|
meta.Commit = "deadbee"
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
printVersionTo(&buf)
|
||||||
|
out := buf.String()
|
||||||
|
|
||||||
|
if !strings.Contains(out, "9.9.9") {
|
||||||
|
t.Fatalf("版本号未出现在输出里(-X 注入会失效):\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "hmapdev") {
|
||||||
|
t.Fatalf("输出里没有工具名:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "deadbee") {
|
||||||
|
t.Fatalf("提交号未出现在输出里:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "HomeAgent") && !strings.Contains(out, "homeagent-sdk") {
|
||||||
|
t.Fatalf("输出里没有 SDK 模块标识:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未注入时(源码默认值)也必须能报——否则开发构建和发版构建长得一样。
|
||||||
|
func TestPrintVersionWorksWithoutInjection(t *testing.T) {
|
||||||
|
origCommit, origBuildTime := meta.Commit, meta.BuildTime
|
||||||
|
defer func() { meta.Commit, meta.BuildTime = origCommit, origBuildTime }()
|
||||||
|
meta.Commit, meta.BuildTime = "unknown", "unknown"
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
printVersionTo(&buf)
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, meta.Version) {
|
||||||
|
t.Fatalf("未注入时应报出源码默认版本 %q:\n%s", meta.Version, out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "unknown") {
|
||||||
|
t.Fatalf("unknown 字段不应出现在输出里(噪声):\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
91
tools/hmapdev/proc_runtime.go
Normal file
91
tools/hmapdev/proc_runtime.go
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 子进程插件运行时(外部插件多进程化)。
|
||||||
|
//
|
||||||
|
// 模板为何是**真实 .go 源文件** + //go:embed,而不是 raw string:
|
||||||
|
// 1100+ 行代码塞在字符串里写错只能等生成插件时才炸;作为源文件可被
|
||||||
|
// gofmt / go vet / go/parser 直接检查(proc_runtime_test.go 的 16 项
|
||||||
|
// 静态检查就以此为前提)。
|
||||||
|
//
|
||||||
|
// 构建从 `-buildmode=c-shared` + CGO_ENABLED=1 变成普通 `go build` +
|
||||||
|
// CGO_ENABLED=0,交叉编译不再需要目标平台的 C 工具链(§3.1 连带消失项)。
|
||||||
|
//
|
||||||
|
// 设计依据:docs/zh/架构迁移评估.md §3、docs/zh/plugin-migration-plan.md Part 3/6
|
||||||
|
|
||||||
|
//go:embed templates/proc_main.go.tmpl
|
||||||
|
//go:embed templates/proc_shm_unix.go.tmpl
|
||||||
|
//go:embed templates/proc_shm_windows.go.tmpl
|
||||||
|
var procTemplates embed.FS
|
||||||
|
|
||||||
|
// procRuntimeFiles 列出生成到插件目录的运行时文件。
|
||||||
|
//
|
||||||
|
// 共享段与事件通知的**传递机制**按平台不同(Unix 继承 fd,
|
||||||
|
// Windows 命名内核对象),故拆成带 build tag 的两个文件;
|
||||||
|
// 共享段**布局**与 RPC 逻辑完全平台无关,全在 proc_main 里。
|
||||||
|
//
|
||||||
|
// 这正是三套独立 ABI 实现收敛为单一 RPC 实现的效果:
|
||||||
|
// 平台差异从「整套 stage 下发/写回逻辑各写一份」缩到「三个挂载函数」。
|
||||||
|
var procRuntimeFiles = []struct {
|
||||||
|
tmpl string // 内嵌模板路径
|
||||||
|
out string // 生成到插件目录的文件名
|
||||||
|
}{
|
||||||
|
{"templates/proc_main.go.tmpl", "z_proc_gen.go"},
|
||||||
|
{"templates/proc_shm_unix.go.tmpl", "z_proc_shm_unix.go"},
|
||||||
|
{"templates/proc_shm_windows.go.tmpl", "z_proc_shm_windows.go"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// procEntryFile 是子进程插件的入口二进制名(与内核 internal/plugin/dynamic.go 的 binEntry 一致)。
|
||||||
|
//
|
||||||
|
// 全平台同名:进程边界本身就是 ABI 边界,不存在平台特有的动态库扩展名
|
||||||
|
// (对比 C ABI 时代的 .so/.dylib/.dll 三套产物 + 三套 ABI 实现)。
|
||||||
|
const procEntryFile = "plugin.bin"
|
||||||
|
|
||||||
|
// luaEntryFile 是 Lua 插件的入口。Lua 走解释器,不经过 Go 编译。
|
||||||
|
const luaEntryFile = "main.lua"
|
||||||
|
|
||||||
|
// procGenFile 是生成的主运行时文件名(兼容旧注释引用)。
|
||||||
|
// 前缀 z_ 使其在目录列表中排在业务代码之后。
|
||||||
|
const procGenFile = "z_proc_gen.go"
|
||||||
|
|
||||||
|
// generateProcRuntime 把子进程运行时(平台无关主体 + 两个平台挂载实现)
|
||||||
|
// 写入插件目录,返回清理函数。
|
||||||
|
func generateProcRuntime() (func(), error) {
|
||||||
|
// 清理历史 C ABI 产物:旧版 hmapdev(原名 plugindev)生成过这两个文件,残留下来会与
|
||||||
|
// 本模板的 main 冲突。无需人工清理就能从旧版升级。
|
||||||
|
for _, stale := range []string{"z_bridge_gen.go", "z_entry.c"} {
|
||||||
|
os.Remove(stale)
|
||||||
|
}
|
||||||
|
|
||||||
|
var written []string
|
||||||
|
cleanup := func() {
|
||||||
|
for _, f := range written {
|
||||||
|
os.Remove(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rf := range procRuntimeFiles {
|
||||||
|
data, err := procTemplates.ReadFile(rf.tmpl)
|
||||||
|
if err != nil {
|
||||||
|
cleanup()
|
||||||
|
return nil, fmt.Errorf("读取内嵌模板 %s: %w", rf.tmpl, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(rf.out, data, 0644); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return nil, fmt.Errorf("写入 %s: %w", rf.out, err)
|
||||||
|
}
|
||||||
|
written = append(written, rf.out)
|
||||||
|
}
|
||||||
|
return cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isProcEntry 已删除:Go 插件一律产出 plugin.bin,不再看 plg.json 的 entry 值。
|
||||||
|
//
|
||||||
|
// 为何忽略 entry:17 个存量插件的 plg.json 都写着 "plugin.so"。若把 entry 当作
|
||||||
|
// 通道开关,迁移就得改 17 个文件——而「外部插件零改动」是本次迁移的硬约束。
|
||||||
|
// entry 现在只用于区分 Lua(main.lua)与 Go 插件。
|
||||||
462
tools/hmapdev/proc_runtime_test.go
Normal file
462
tools/hmapdev/proc_runtime_test.go
Normal file
@ -0,0 +1,462 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 子进程运行时模板的静态检查(Part 3)。
|
||||||
|
//
|
||||||
|
// 为什么需要这些测试:模板是插件的运行时半身,它与内核 internal/plugin/proc/
|
||||||
|
// 的协议名、共享段布局、字段索引必须逐一对齐。任一处漂移都会导致
|
||||||
|
// 「插件编译通过但运行时读错字段」——比编译错误难查得多。
|
||||||
|
//
|
||||||
|
// 模板改为真实 .go 源文件(而非 raw string)的直接收益就是这类检查可行。
|
||||||
|
|
||||||
|
func loadProcTemplate(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
data, err := procTemplates.ReadFile("templates/proc_main.go.tmpl")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取内嵌模板: %v", err)
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripComments 去掉源码中的注释(用空白填充以保持偏移),只留可执行代码。
|
||||||
|
func stripComments(t *testing.T, src string) string {
|
||||||
|
t.Helper()
|
||||||
|
fs := token.NewFileSet()
|
||||||
|
f, err := parser.ParseFile(fs, "proc_main.go", src, parser.ParseComments)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("解析模板: %v", err)
|
||||||
|
}
|
||||||
|
out := []byte(src)
|
||||||
|
for _, cg := range f.Comments {
|
||||||
|
s := fs.Position(cg.Pos()).Offset
|
||||||
|
e := fs.Position(cg.End()).Offset
|
||||||
|
for i := s; i < e && i < len(out); i++ {
|
||||||
|
if out[i] != '\n' {
|
||||||
|
out[i] = ' '
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模板必须是合法 Go 源码。
|
||||||
|
func TestProcTemplate_ParsesAsGo(t *testing.T) {
|
||||||
|
src := loadProcTemplate(t)
|
||||||
|
fs := token.NewFileSet()
|
||||||
|
if _, err := parser.ParseFile(fs, "proc_main.go", src, parser.AllErrors); err != nil {
|
||||||
|
t.Fatalf("模板不是合法 Go 源码: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模板必须提供 main(),且不得含 cgo 痕迹。
|
||||||
|
//
|
||||||
|
// 零 cgo 是迁移的核心收益之一(§3.7 锁仲裁回内核后整个架构无 cgo);
|
||||||
|
// 一旦有人往模板里加 import "C",交叉编译立刻退回需要目标平台 C 工具链。
|
||||||
|
func TestProcTemplate_HasMainAndNoCgo(t *testing.T) {
|
||||||
|
src := loadProcTemplate(t)
|
||||||
|
|
||||||
|
if !strings.Contains(src, "func main()") {
|
||||||
|
t.Error("子进程模板必须有 main() 入口")
|
||||||
|
}
|
||||||
|
// 只检查代码,不检查注释——模板顶部的说明文字本身就提到了 C.CString/C.free
|
||||||
|
code := stripComments(t, src)
|
||||||
|
for _, forbidden := range []string{
|
||||||
|
`import "C"`,
|
||||||
|
"//export ",
|
||||||
|
"C.CString",
|
||||||
|
"C.GoString",
|
||||||
|
"C.free",
|
||||||
|
} {
|
||||||
|
if strings.Contains(code, forbidden) {
|
||||||
|
t.Errorf("模板不应含 cgo 痕迹 %q(零 cgo 是迁移的核心收益)", forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模板引用的 method 名必须与内核 internal/plugin/proc/protocol.go 一致。
|
||||||
|
//
|
||||||
|
// 这里硬编码一份清单做对照:内核侧改了 method 名而模板没跟上时,
|
||||||
|
// 表现是插件调用返回「未知 method」,测试能提前拦住。
|
||||||
|
func TestProcTemplate_CoversAllCoreMethods(t *testing.T) {
|
||||||
|
src := loadProcTemplate(t)
|
||||||
|
|
||||||
|
// 51 个 C ABI method id 平移后的名字(§3.2),加 stage 锁仲裁 2 个
|
||||||
|
required := []string{
|
||||||
|
// 注册面
|
||||||
|
"tool.register", "stage.register", "output.register", "api.register", "input.register",
|
||||||
|
// IO 注入(io.injectTextNoMem 见下方 deprecated:内核保留为兼容旧二进制,
|
||||||
|
// 当前模板改走 io.injectText + NoMemory 标志位,不再发那个 id)
|
||||||
|
"io.injectText", "io.injectInterrupt", "io.injectInputSync",
|
||||||
|
"io.setToolBlocks",
|
||||||
|
// 多模态注入(1.1.0 新增)。漏接线的后果是插件调 InjectInputMedia 静默无效果:
|
||||||
|
// 模板不发这个 RPC,内核也就永远收不到,而两边都不报错。
|
||||||
|
"io.injectMedia", "io.injectMediaSync", "io.injectInterruptMedia",
|
||||||
|
// 生命周期
|
||||||
|
"lifecycle.autoRestart",
|
||||||
|
// 图记忆
|
||||||
|
"memory.recall", "memory.commit", "memory.introspect", "memory.merge", "memory.purge",
|
||||||
|
// 文档记忆
|
||||||
|
"doc.query", "doc.insert", "doc.remove", "doc.stats",
|
||||||
|
// 文档媒体(1.1.0 新增)
|
||||||
|
"doc.insertWithMedia",
|
||||||
|
// 知识库
|
||||||
|
"knowledge.search", "knowledge.add", "knowledge.list",
|
||||||
|
// 文本记忆
|
||||||
|
"textmemory.append",
|
||||||
|
// 设置
|
||||||
|
"settings.get", "settings.set", "settings.registerDef",
|
||||||
|
"settings.getCore", "settings.setCore", "settings.listCore",
|
||||||
|
"settings.getPlugin", "settings.setPlugin", "settings.listPlugin",
|
||||||
|
"settings.list", "settings.defs", "settings.dump", "settings.plugins",
|
||||||
|
"settings.dataDir",
|
||||||
|
// LLM
|
||||||
|
"llm.listSources", "llm.setSource", "llm.currentSource",
|
||||||
|
// 社交图
|
||||||
|
"social.getPerson", "social.getNetwork", "social.getTrait",
|
||||||
|
"social.getRelations", "social.listPersons",
|
||||||
|
// 插件管理
|
||||||
|
"plugin.reloadOne", "plugin.listLoaded", "plugin.isDisabled",
|
||||||
|
// 共享段锁仲裁(新增,C ABI 下不存在此概念)
|
||||||
|
"stage.lock", "stage.unlock",
|
||||||
|
}
|
||||||
|
for _, m := range required {
|
||||||
|
if !strings.Contains(src, `"`+m+`"`) {
|
||||||
|
t.Errorf("模板缺少 core method %q(内核已提供,插件侧未接线)", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内核保留、但**当前模板不再发送**的 method id。
|
||||||
|
//
|
||||||
|
// 它们不是「公开接口新增却忘记接线」,而是刻意的向后兼容面:
|
||||||
|
// 内核必须继续接受用旧模板编出的插件二进制发来的 id,而当前模板没有理由再发。
|
||||||
|
//
|
||||||
|
// 可复查的判据:ba49dfd 之前的模板里 InjectTextNoMemory 发的就是
|
||||||
|
// "io.injectTextNoMem";注入标志位落地后它改走 "io.injectText" + NoMemory。
|
||||||
|
//
|
||||||
|
// 为何要单独列而不是直接从 required 删掉:这条守卫的价值在于「新接口必须接线」,
|
||||||
|
// 而把「内核有、模板就必须发」当不变量,会让它常驻误报——常驻误报的守卫迟早
|
||||||
|
// 被人习惯性忽略,那时真漏接线也就没人看见了。
|
||||||
|
deprecated := map[string]string{
|
||||||
|
"io.injectTextNoMem": "旧模板经此表达「不进记忆」;现由 io.injectText + NoMemory 表达",
|
||||||
|
}
|
||||||
|
// 反向保护:allowlist 条目一旦又出现在模板里,说明它已过期,必须删掉,
|
||||||
|
// 否则这里会悄悄变成「永久豁免」的垃圾抽屉。
|
||||||
|
for m, why := range deprecated {
|
||||||
|
if strings.Contains(src, `"`+m+`"`) {
|
||||||
|
t.Errorf("deprecated 里的 %q 又出现在模板里(%s)——条目已过期,请从 deprecated 移除", m, why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模板必须处理内核发来的全部调用(含无法 JSON 序列化的 Cleaner 回调)。
|
||||||
|
func TestProcTemplate_HandlesAllKernelCalls(t *testing.T) {
|
||||||
|
src := loadProcTemplate(t)
|
||||||
|
for _, m := range []string{
|
||||||
|
"handshake",
|
||||||
|
"plugin.init", "plugin.start", "plugin.stop",
|
||||||
|
"tool.invoke", "cleaner.invoke", "stage.invoke", "output.invoke",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(src, `case "`+m+`"`) {
|
||||||
|
t.Errorf("模板未处理内核调用 %q", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具调用的 payload 必须走内核标定的**调用帧**(funccall 模型)。
|
||||||
|
//
|
||||||
|
// 共享内存是内核内部实现(插件作者只看到普通 map),但模板必须在传输层
|
||||||
|
// 正确读写 frame / args_len / result_ref。漏接线的后果很隐蔽:参数被静默
|
||||||
|
// 丢弃、结果只走内联,性能退化而不报错。
|
||||||
|
func TestProcTemplate_ToolInvokeUsesSharedRef(t *testing.T) {
|
||||||
|
src := loadProcTemplate(t)
|
||||||
|
for _, want := range []string{"frame", "args_len", "result_ref"} {
|
||||||
|
if !strings.Contains(src, want) {
|
||||||
|
t.Errorf("模板的 tool.invoke 必须处理 %q(payload 走内核标定的调用帧)", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模板必须通过 arena.alloc / arena.free 向内核申请与归还共享内存。
|
||||||
|
//
|
||||||
|
// 共享内存是内核独占管理的**内部实现**:插件不能自己维护分配游标。
|
||||||
|
// 历史上两版跨进程分配器(bump 游标 / 模板内位图 CAS)都因为把可变
|
||||||
|
// 分配状态放在共享内存里而出竞态,所以这里做回归保护。
|
||||||
|
func TestProcTemplate_UsesKernelArenaRPC(t *testing.T) {
|
||||||
|
src := loadProcTemplate(t)
|
||||||
|
|
||||||
|
for _, m := range []string{`"arena.alloc"`, `"arena.free"`} {
|
||||||
|
if !strings.Contains(src, m) {
|
||||||
|
t.Errorf("模板缺少内核共享内存 RPC %s(插件必须向内核申请/归还)", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 禁止插件侧再出现本地分配器符号。
|
||||||
|
//
|
||||||
|
// 只查代码不查注释:注释里会解释“为什么不再这么做”。
|
||||||
|
code := stripComments(t, src)
|
||||||
|
for _, forbidden := range []string{"arenaUsed", "arenaWrite"} {
|
||||||
|
if strings.Contains(code, forbidden) {
|
||||||
|
t.Errorf("模板不应再出现插件侧分配器 %q(共享内存由内核独占管理)", forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 共享段布局常量必须与内核 internal/plugin/proc/shm.go 一致。
|
||||||
|
//
|
||||||
|
// 字段索引错位是最危险的漂移:插件会读到相邻字段的数据,
|
||||||
|
// 而两边都不报错(同为 []byte)。
|
||||||
|
func TestProcTemplate_ShmLayoutMatchesKernel(t *testing.T) {
|
||||||
|
src := loadProcTemplate(t)
|
||||||
|
|
||||||
|
// 与内核 shm.go 的 offXxx 常量对齐(值比较,不依赖 gofmt 的对齐空白)
|
||||||
|
layout := map[string]string{
|
||||||
|
"shmOffMagic": "0",
|
||||||
|
"shmOffVersion": "4",
|
||||||
|
"shmOffArenaBase": "8",
|
||||||
|
"shmOffArenaCap": "12",
|
||||||
|
"shmOffArenaUsed": "16",
|
||||||
|
"shmOffCtxBase": "20",
|
||||||
|
"shmOffSeq": "24",
|
||||||
|
// 与内核 stageFieldCount / sliceSize 对齐
|
||||||
|
"shmStageFieldCount": "18",
|
||||||
|
"shmSliceSize": "8",
|
||||||
|
"shmVersion": "1",
|
||||||
|
}
|
||||||
|
constRe := func(name, want string) bool {
|
||||||
|
// gofmt 会对齐常量块,故容许 name 与 = 之间有任意空白
|
||||||
|
re := regexp.MustCompile(`\b` + regexp.QuoteMeta(name) + `\s*=\s*` + regexp.QuoteMeta(want) + `\b`)
|
||||||
|
return re.MatchString(src)
|
||||||
|
}
|
||||||
|
for name, want := range layout {
|
||||||
|
if !constRe(name, want) {
|
||||||
|
t.Errorf("共享段常量 %s 应为 %s(须与内核 internal/plugin/proc/shm.go 一致)", name, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字段枚举顺序:内核 stageField 的前若干项
|
||||||
|
fieldOrder := []string{
|
||||||
|
"fRawMessage = iota", "fUserID", "fGroupID", "fLLMText",
|
||||||
|
"fReasoningContent", "fFinalText", "fResponse", "fPhase",
|
||||||
|
"fContextMsgs", "fToolCalls", "fToolResults", "fMemory",
|
||||||
|
"fTokenUsage", "fErrors",
|
||||||
|
"fExtraMediaBlocks", "fExtraMediaType", "fExtraInputSource", "fExtraOutputChannel",
|
||||||
|
}
|
||||||
|
idx := -1
|
||||||
|
for _, f := range fieldOrder {
|
||||||
|
at := strings.Index(src, f)
|
||||||
|
if at < 0 {
|
||||||
|
t.Fatalf("模板缺少字段常量 %s", f)
|
||||||
|
}
|
||||||
|
if at <= idx {
|
||||||
|
t.Errorf("字段常量 %s 的声明顺序与内核 stageField 枚举不一致", f)
|
||||||
|
}
|
||||||
|
idx = at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stage 处理必须「拿锁 → 读 → handler → 只写脏字段 → 放锁」。
|
||||||
|
//
|
||||||
|
// 只写脏字段是消除 lost update 的核心:只读插件零写入,
|
||||||
|
// 不可能覆盖其他插件的改写(对照 C ABI 副本模型实测 35.8~36.8% 丢失)。
|
||||||
|
func TestProcTemplate_StageFlowUsesLockAndDirtyWrite(t *testing.T) {
|
||||||
|
src := loadProcTemplate(t)
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"func handleStageInvoke(",
|
||||||
|
"stage.lock",
|
||||||
|
"readStageContext()",
|
||||||
|
"takeStageSnapshot(",
|
||||||
|
"writeStageDirty(",
|
||||||
|
"stage.unlock",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(src, want) {
|
||||||
|
t.Errorf("stage 处理链路缺少 %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 顺序检查:加锁必须在读取之前,写回必须在解锁之前
|
||||||
|
iLock := strings.Index(src, `callCoreVoid("stage.lock"`)
|
||||||
|
iRead := strings.Index(src, "readStageContext()")
|
||||||
|
iWrite := strings.Index(src, "writeStageDirty(sc, snap)")
|
||||||
|
if iLock < 0 || iRead < 0 || iWrite < 0 {
|
||||||
|
t.Fatal("stage 链路关键调用缺失")
|
||||||
|
}
|
||||||
|
// readStageContext 的定义在前,调用在后;取 handleStageInvoke 内的位置
|
||||||
|
stageFn := src[strings.Index(src, "func handleStageInvoke("):]
|
||||||
|
iLockFn := strings.Index(stageFn, `callCoreVoid("stage.lock"`)
|
||||||
|
iReadFn := strings.Index(stageFn, "readStageContext()")
|
||||||
|
iWriteFn := strings.Index(stageFn, "writeStageDirty(sc, snap)")
|
||||||
|
if !(iLockFn < iReadFn && iReadFn < iWriteFn) {
|
||||||
|
t.Error("stage 链路顺序应为 加锁 → 读取 → 写回")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 快照必须存序列化字符串而非 Go 值。
|
||||||
|
//
|
||||||
|
// ❗ 这是修 C ABI 侧 11.3 时踩过的坑:StageContext 的切片字段与读出的值
|
||||||
|
// 共享底层内容,handler 原地改元素(sc.ToolResults[0].Result = x)时,
|
||||||
|
// 直接持有 Go 值的快照会跟着变,脏字段计算失效、修复静默失效。
|
||||||
|
func TestProcTemplate_SnapshotStoresSerializedStrings(t *testing.T) {
|
||||||
|
src := loadProcTemplate(t)
|
||||||
|
|
||||||
|
if !strings.Contains(src, "strs map[int]string") ||
|
||||||
|
!strings.Contains(src, "jsons map[int]string") {
|
||||||
|
t.Error("stageSnapshot 必须存序列化字符串(切片共享底层数组,存 Go 值会让脏字段计算失效)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(src, "json.Marshal(v)") {
|
||||||
|
t.Error("takeStageSnapshot 应对容器字段做 json.Marshal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// arena 用尽必须显式报错,不得静默截断(§4.4 风险登记)。
|
||||||
|
func TestProcTemplate_ArenaExhaustionErrors(t *testing.T) {
|
||||||
|
src := loadProcTemplate(t)
|
||||||
|
if !strings.Contains(src, "arena 空间不足") {
|
||||||
|
t.Error("shmWrite 在 arena 不足时必须报错,不得静默截断")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 日志必须走 stderr:stdout 是 RPC 通道,写日志会破坏 NDJSON 帧。
|
||||||
|
func TestProcTemplate_LogsToStderr(t *testing.T) {
|
||||||
|
src := loadProcTemplate(t)
|
||||||
|
if !strings.Contains(src, "log.SetOutput(os.Stderr)") {
|
||||||
|
t.Error("日志必须走 stderr,否则会破坏 stdout 的 RPC 帧")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 请求必须在独立 goroutine 里处理。
|
||||||
|
//
|
||||||
|
// handler 内会反向调用内核并等应答;若在读循环里同步处理,
|
||||||
|
// 就没人读应答帧 → 死锁。
|
||||||
|
func TestProcTemplate_DispatchesRequestsConcurrently(t *testing.T) {
|
||||||
|
src := loadProcTemplate(t)
|
||||||
|
if !strings.Contains(src, "go handleKernelRequest(&req)") {
|
||||||
|
t.Error("请求须在独立 goroutine 处理(handler 内反向调用内核,同步处理会死锁)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 协议与共享内存区域版本/魔数不匹配必须拒绝,不得半兼容运行。
|
||||||
|
func TestProcTemplate_RejectsVersionMismatch(t *testing.T) {
|
||||||
|
src := loadProcTemplate(t)
|
||||||
|
// §13.1 起共享段合并为单一「统一区域」,魔数校验文案随之更新。
|
||||||
|
for _, want := range []string{"协议版本不匹配", "共享段版本不匹配", "统一区域魔数不匹配"} {
|
||||||
|
if !strings.Contains(src, want) {
|
||||||
|
t.Errorf("握手应校验并拒绝 %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全平台统一产出 plugin.bin。
|
||||||
|
//
|
||||||
|
// 这是三套独立 ABI 实现(.so/.dylib/.dll)收敛为单一 RPC 实现的直接后果:
|
||||||
|
// 进程边界本身就是 ABI 边界,不存在平台特有的动态库扩展名。
|
||||||
|
// §9.2 记录的「Windows DLL 路径只下发 3 字段、无写回」随之消失——
|
||||||
|
// Windows 走的是与 Linux 完全相同的 RPC 实现。
|
||||||
|
func TestResolveBuild_AllPlatformsProduceBin(t *testing.T) {
|
||||||
|
for _, target := range []string{
|
||||||
|
"linux/amd64", "linux/arm64",
|
||||||
|
"darwin/amd64", "darwin/arm64",
|
||||||
|
"windows/amd64",
|
||||||
|
"freebsd/amd64",
|
||||||
|
} {
|
||||||
|
cfg, errMsg := resolveBuild(target)
|
||||||
|
if cfg == nil {
|
||||||
|
t.Fatalf("resolveBuild(%q) 失败: %s", target, errMsg)
|
||||||
|
}
|
||||||
|
if cfg.entryFile != procEntryFile {
|
||||||
|
t.Errorf("%s: 产物应为 %s,实际 %s", target, procEntryFile, cfg.entryFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lua 目标仍走解释器路径(entry 字段唯一仍在使用的用途)。
|
||||||
|
func TestResolveBuild_LuaIsSeparatePath(t *testing.T) {
|
||||||
|
for _, target := range []string{"lua", ""} {
|
||||||
|
cfg, kind := resolveBuild(target)
|
||||||
|
if cfg != nil {
|
||||||
|
t.Errorf("%q 应返回 nil cfg(Lua 不经 Go 编译)", target)
|
||||||
|
}
|
||||||
|
if kind != "lua" {
|
||||||
|
t.Errorf("%q 应识别为 lua,实际 %q", target, kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不支持的平台明确报错,不静默产出错误产物。
|
||||||
|
func TestResolveBuild_UnsupportedOSErrors(t *testing.T) {
|
||||||
|
cfg, errMsg := resolveBuild("plan9/amd64")
|
||||||
|
if cfg != nil {
|
||||||
|
t.Error("不支持的平台应返回 nil cfg")
|
||||||
|
}
|
||||||
|
if !strings.Contains(errMsg, "unsupported") {
|
||||||
|
t.Errorf("应给出 unsupported 提示,实际 %q", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bundle 产物在 zip 内按平台加后缀(全平台同名 plugin.bin 会相互覆盖)。
|
||||||
|
func TestBundleTargets_HavePlatformSuffixedEntries(t *testing.T) {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, bt := range allBundleTargets {
|
||||||
|
if seen[bt.entry] {
|
||||||
|
t.Errorf("zip 条目名重复: %s(会相互覆盖)", bt.entry)
|
||||||
|
}
|
||||||
|
seen[bt.entry] = true
|
||||||
|
if !strings.HasPrefix(bt.entry, procEntryFile+".") {
|
||||||
|
t.Errorf("bundle 条目 %q 应以 %s. 为前缀", bt.entry, procEntryFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(allBundleTargets) == 0 {
|
||||||
|
t.Error("bundle 目标表不应为空")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// C ABI 工具链残留必须彻底清除:不得再有 .so/.dylib/.dll 产物路径,
|
||||||
|
// 也不得再引用 c-shared 构建模式或 MinGW 探测。
|
||||||
|
func TestToolchain_NoCABIResiduals(t *testing.T) {
|
||||||
|
for _, f := range []string{"cmd_build.go", "templates.go", "cmd_init.go", "proc_runtime.go"} {
|
||||||
|
data, err := os.ReadFile(f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读 %s: %v", f, err)
|
||||||
|
}
|
||||||
|
src := stripComments(t, string(data))
|
||||||
|
for _, forbidden := range []string{
|
||||||
|
"c-shared",
|
||||||
|
"CGO_ENABLED=1",
|
||||||
|
"detectWindowsCC",
|
||||||
|
"generateBridge",
|
||||||
|
"tmplLinuxBridge",
|
||||||
|
"tmplPluginInitC",
|
||||||
|
} {
|
||||||
|
if strings.Contains(src, forbidden) {
|
||||||
|
t.Errorf("%s 仍含 C ABI 残留 %q", f, forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go 插件的构建不再读 plg.json 的 entry 值。
|
||||||
|
//
|
||||||
|
// 这是「外部插件零改动」的关键:17 个存量插件的 plg.json 都写着 "plugin.so",
|
||||||
|
// 若把 entry 当通道开关,迁移就得改 17 个文件。
|
||||||
|
func TestToolchain_IgnoresEntryForGoPlugins(t *testing.T) {
|
||||||
|
data, err := os.ReadFile("cmd_build.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读 cmd_build.go: %v", err)
|
||||||
|
}
|
||||||
|
src := stripComments(t, string(data))
|
||||||
|
if strings.Contains(src, "isProcEntry") {
|
||||||
|
t.Error("isProcEntry 应已删除——Go 插件一律产出 plugin.bin,不看 entry 值")
|
||||||
|
}
|
||||||
|
// entry 仅剩 Lua 判定这一处用途
|
||||||
|
if !strings.Contains(src, "luaEntryFile") {
|
||||||
|
t.Error("IsLua 应改用 luaEntryFile 常量")
|
||||||
|
}
|
||||||
|
}
|
||||||
135
tools/hmapdev/sdk_resolve.go
Normal file
135
tools/hmapdev/sdk_resolve.go
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sdkVersionEntry 是本地 SDK 存储里的一个版本。
|
||||||
|
//
|
||||||
|
// Dir 是存储目录名(历史上有 "v0.8.0" 与 "1.2.0" 两种写法都出现过,所以
|
||||||
|
// 目录名与规范化版本号要分开存),Version 是去掉 v 前缀的 x.y.z。
|
||||||
|
type sdkVersionEntry struct {
|
||||||
|
Dir string
|
||||||
|
Version string
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeSDKVersion 去掉常见的前缀写法,得到 x.y.z。
|
||||||
|
func normalizeSDKVersion(v string) string {
|
||||||
|
return strings.TrimPrefix(strings.TrimSpace(v), "v")
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSDKVersion 解析 x.y.z / x.y(后者补 0)。
|
||||||
|
func parseSDKVersion(v string) (maj, min, patch int, ok bool) {
|
||||||
|
v = normalizeSDKVersion(v)
|
||||||
|
parts := strings.Split(v, ".")
|
||||||
|
if len(parts) < 2 || len(parts) > 3 {
|
||||||
|
return 0, 0, 0, false
|
||||||
|
}
|
||||||
|
nums := make([]int, 0, 3)
|
||||||
|
for _, p := range parts {
|
||||||
|
n, err := strconv.Atoi(p)
|
||||||
|
if err != nil || n < 0 {
|
||||||
|
return 0, 0, 0, false
|
||||||
|
}
|
||||||
|
nums = append(nums, n)
|
||||||
|
}
|
||||||
|
for len(nums) < 3 {
|
||||||
|
nums = append(nums, 0)
|
||||||
|
}
|
||||||
|
return nums[0], nums[1], nums[2], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// compareSDKVersion 比较两个 x.y.z(a<b 返回 -1,相等 0,a>b 返回 1)。
|
||||||
|
func compareSDKVersion(a, b string) int {
|
||||||
|
amaj, amin, apat, aok := parseSDKVersion(a)
|
||||||
|
bmaj, bmin, bpat, bok := parseSDKVersion(b)
|
||||||
|
if !aok || !bok {
|
||||||
|
return strings.Compare(normalizeSDKVersion(a), normalizeSDKVersion(b))
|
||||||
|
}
|
||||||
|
for _, d := range [][2]int{{amaj, bmaj}, {amin, bmin}, {apat, bpat}} {
|
||||||
|
switch {
|
||||||
|
case d[0] < d[1]:
|
||||||
|
return -1
|
||||||
|
case d[0] > d[1]:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// listInstalledSDKs 列出存储里已安装的 SDK,按版本升序。
|
||||||
|
func listInstalledSDKs() []sdkVersionEntry {
|
||||||
|
store := sdkStore()
|
||||||
|
entries, err := os.ReadDir(store)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []sdkVersionEntry
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := e.Name()
|
||||||
|
if name == "current" || strings.HasPrefix(name, ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := normalizeSDKVersion(name)
|
||||||
|
if _, _, _, ok := parseSDKVersion(v); !ok {
|
||||||
|
continue // 非版本目录(用户放别的东西进去时不误判)
|
||||||
|
}
|
||||||
|
out = append(out, sdkVersionEntry{Dir: name, Version: v})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return compareSDKVersion(out[i].Version, out[j].Version) < 0 })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveSDKForProject 按项目声明的 SDK 版本(plg.json 的 "sdk" 字段)在本地存储里定位 SDK。
|
||||||
|
//
|
||||||
|
// 声明必须是**完整版本号**(x.y.z,如 "1.2.0"):SDK 的版本纪律是「跟随内核中版本,
|
||||||
|
// patch 位恒为 .0」(内核的 patch 不碰公开接口 → SDK 不跟版),所以一条内核线
|
||||||
|
// 只对应一个 SDK 版本号,写 "1.2" 这种区间写法既不必要、又容易让人以为
|
||||||
|
// 「同一条线里还能挑不同 SDK」。工具链直接拒它,顺便把这条规矩说清楚。
|
||||||
|
//
|
||||||
|
// 找不到时必须报**可执行**的错误:列出已装版本 + 可直接粘贴的安装命令 ——
|
||||||
|
// 只说 "not found" 会让人以为是工具链坏了。
|
||||||
|
func ResolveSDKForProject(declared string) (dir, version string, err error) {
|
||||||
|
declared = normalizeSDKVersion(declared)
|
||||||
|
if strings.Count(declared, ".") != 2 {
|
||||||
|
return "", "", fmt.Errorf(
|
||||||
|
"plg.json 的 sdk 字段 %q 必须是完整版本号(如 \"1.2.0\")——\n"+
|
||||||
|
" SDK 版本跟随内核中版本、patch 位恒为 .0,一条内核线只有一个 SDK 版本", declared)
|
||||||
|
}
|
||||||
|
maj, min, pat, ok := parseSDKVersion(declared)
|
||||||
|
if !ok {
|
||||||
|
return "", "", fmt.Errorf("plg.json 的 sdk 字段 %q 不是合法版本号(写法:\"1.2.0\")", declared)
|
||||||
|
}
|
||||||
|
|
||||||
|
installed := listInstalledSDKs()
|
||||||
|
for i := range installed {
|
||||||
|
e := installed[i]
|
||||||
|
emaj, emin, epat, _ := parseSDKVersion(e.Version)
|
||||||
|
if emaj == maj && emin == min && epat == pat {
|
||||||
|
return filepath.Join(sdkStore(), e.Dir), e.Version, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未命中:给出可执行的下一步
|
||||||
|
var have []string
|
||||||
|
for _, e := range installed {
|
||||||
|
have = append(have, e.Version)
|
||||||
|
}
|
||||||
|
avail := "(存储里还没有任何 SDK)"
|
||||||
|
if len(have) > 0 {
|
||||||
|
avail = "已安装:" + strings.Join(have, ", ")
|
||||||
|
}
|
||||||
|
return "", "", fmt.Errorf(
|
||||||
|
"项目声明 sdk=%s,但本地 SDK 存储里没有这个版本;%s\n"+
|
||||||
|
" 安装:hmapdev sdk install v%s\n"+
|
||||||
|
" 查看:hmapdev sdk list",
|
||||||
|
declared, avail, declared)
|
||||||
|
}
|
||||||
103
tools/hmapdev/sdk_resolve_test.go
Normal file
103
tools/hmapdev/sdk_resolve_test.go
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// withSDKStore 把 SDK 存储指到临时目录(sdkStore 读 HOME),并造出给定版本目录。
|
||||||
|
func withSDKStore(t *testing.T, versions ...string) {
|
||||||
|
t.Helper()
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
store := filepath.Join(home, ".homeagent", sdkDirName)
|
||||||
|
if err := os.MkdirAll(store, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, v := range versions {
|
||||||
|
if err := os.MkdirAll(filepath.Join(store, v), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 项目声明中版本 → 挑该接口线里最新的补丁;声明完整版本 → 精确命中。
|
||||||
|
//
|
||||||
|
// 为什么允许中版本是关键判据:patch 位只含工具链/打包修复(接口不变),
|
||||||
|
// 让项目声明 "1.2" 而不是死钉 "1.2.0",才能既跟得上工具链修复又不跨接口线。
|
||||||
|
func TestResolveSDKForProject(t *testing.T) {
|
||||||
|
t.Run("区间写法(1.2)被拒并说明版本纪律", func(t *testing.T) {
|
||||||
|
// 判据:SDK 的 patch 位恒为 .0 → 一条内核线只有一个 SDK 版本,
|
||||||
|
// 区间写法会让人误以为「同一条线里还能挑版本」,所以直接拒。
|
||||||
|
withSDKStore(t, "1.2.0")
|
||||||
|
_, _, err := ResolveSDKForProject("1.2")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("1.2 这种区间写法应被拒绝")
|
||||||
|
}
|
||||||
|
for _, want := range []string{"完整版本号", "patch 位恒为 .0"} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Fatalf("拒绝理由里应说清规矩(缺少 %q): %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("完整版本精确命中", func(t *testing.T) {
|
||||||
|
withSDKStore(t, "1.2.0", "1.3.0")
|
||||||
|
_, ver, err := ResolveSDKForProject("1.2.0")
|
||||||
|
if err != nil || ver != "1.2.0" {
|
||||||
|
t.Fatalf("精确命中失败: ver=%s err=%v", ver, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("存储目录带 v 前缀也能命中", func(t *testing.T) {
|
||||||
|
withSDKStore(t, "v1.2.0")
|
||||||
|
dir, ver, err := ResolveSDKForProject("1.2.0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ver != "1.2.0" || !strings.HasSuffix(dir, "v1.2.0") {
|
||||||
|
t.Fatalf("带 v 前缀的目录名未被识别: dir=%s ver=%s", dir, ver)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("未命中要给出可执行命令与已装清单", func(t *testing.T) {
|
||||||
|
withSDKStore(t, "1.2.0")
|
||||||
|
_, _, err := ResolveSDKForProject("2.0.0")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("应报错")
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
for _, want := range []string{"sdk=2.0.0", "hmapdev sdk install", "hmapdev sdk list", "1.2.0"} {
|
||||||
|
if !strings.Contains(msg, want) {
|
||||||
|
t.Fatalf("错误信息缺少 %q(要可执行,不能只说 not found):\n%s", want, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("空存储也能给出安装指引", func(t *testing.T) {
|
||||||
|
withSDKStore(t)
|
||||||
|
_, _, err := ResolveSDKForProject("1.2.0")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "hmapdev sdk install v1.2.0") {
|
||||||
|
t.Fatalf("空存储时应提示装哪个版本: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("非法声明直接拒绝", func(t *testing.T) {
|
||||||
|
withSDKStore(t, "1.2.0")
|
||||||
|
for _, bad := range []string{"abc", "1", "1.2", "1.2.3.4", "-1.2.0"} {
|
||||||
|
if _, _, err := ResolveSDKForProject(bad); err == nil {
|
||||||
|
t.Fatalf("非法版本 %q 应被拒绝(宁可报错也不许当通配符)", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("非版本目录不参与匹配", func(t *testing.T) {
|
||||||
|
withSDKStore(t, "1.2.0", "backup-old", ".hidden", "current")
|
||||||
|
_, ver, err := ResolveSDKForProject("1.2.0")
|
||||||
|
if err != nil || ver != "1.2.0" {
|
||||||
|
t.Fatalf("杂项目录不应干扰: ver=%s err=%v", ver, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
247
tools/hmapdev/stagediff_test.go
Normal file
247
tools/hmapdev/stagediff_test.go
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 本测试验证 tmplLinuxBridge 中 snapshotWritable + changedFieldsOnly 的语义(plan.md 11.3)。
|
||||||
|
// 模板字符串本身无法直接单测,这里以同一份逻辑复刻,防止回归。
|
||||||
|
// ❗ 模板与本文件须同步修改。
|
||||||
|
//
|
||||||
|
// 关键陷阱(第一版实现踩过):stageContextWritable 返回的切片字段与 sc 共享底层数组,
|
||||||
|
// handler 原地改元素时"before 快照"会跟着变,diff 看不到变更 → 修复静默失效。
|
||||||
|
// 故 before 必须是**序列化后的字符串快照**。
|
||||||
|
|
||||||
|
func writable(sc *sdk.StageContext) map[string]interface{} {
|
||||||
|
m := map[string]interface{}{
|
||||||
|
"raw_message": sc.RawMessage,
|
||||||
|
"user_id": sc.UserID,
|
||||||
|
"group_id": sc.GroupID,
|
||||||
|
"phase": string(sc.Phase),
|
||||||
|
"llm_text": sc.LLMText,
|
||||||
|
"final_text": sc.FinalText,
|
||||||
|
"no_memory": sc.NoMemory,
|
||||||
|
}
|
||||||
|
if sc.Response != nil {
|
||||||
|
m["response"] = *sc.Response
|
||||||
|
}
|
||||||
|
if len(sc.ToolCalls) > 0 {
|
||||||
|
m["tool_calls"] = sc.ToolCalls
|
||||||
|
}
|
||||||
|
if len(sc.ToolResults) > 0 {
|
||||||
|
m["tool_results"] = sc.ToolResults
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshot 对应模板里的 snapshotWritable:逐字段序列化为不可变快照。
|
||||||
|
func snapshot(sc *sdk.StageContext) map[string]string {
|
||||||
|
snap := map[string]string{}
|
||||||
|
for k, v := range writable(sc) {
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
snap[k] = string(b)
|
||||||
|
}
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
|
||||||
|
// diffOnly 对应模板里的 changedFieldsOnly。
|
||||||
|
func diffOnly(before map[string]string, after map[string]interface{}) map[string]interface{} {
|
||||||
|
diff := map[string]interface{}{}
|
||||||
|
keys := map[string]bool{}
|
||||||
|
for k := range before {
|
||||||
|
keys[k] = true
|
||||||
|
}
|
||||||
|
for k := range after {
|
||||||
|
keys[k] = true
|
||||||
|
}
|
||||||
|
for k := range keys {
|
||||||
|
bRaw, bHas := before[k]
|
||||||
|
a, aHas := after[k]
|
||||||
|
switch {
|
||||||
|
case aHas && !bHas:
|
||||||
|
diff[k] = a
|
||||||
|
case aHas && bHas:
|
||||||
|
ab, _ := json.Marshal(a)
|
||||||
|
if bRaw != string(ab) {
|
||||||
|
diff[k] = a
|
||||||
|
}
|
||||||
|
case bHas && !aHas:
|
||||||
|
switch k {
|
||||||
|
case "tool_calls":
|
||||||
|
diff[k] = []sdk.ToolCall{}
|
||||||
|
case "tool_results":
|
||||||
|
diff[k] = []sdk.ToolResult{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return diff
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只读插件(如 weather 的 AfterToolcall)不改任何字段 → 零回传。
|
||||||
|
// 这是修复 lost update 的关键:旧实现会回传它收到的旧快照,覆盖 sanitizer 的清洗结果。
|
||||||
|
func TestChangedFieldsOnly_ReadOnlyPluginReturnsNothing(t *testing.T) {
|
||||||
|
sc := &sdk.StageContext{
|
||||||
|
RawMessage: "hello",
|
||||||
|
LLMText: "world",
|
||||||
|
ToolResults: []sdk.ToolResult{
|
||||||
|
{CallID: "c1", Name: "weather_query", Success: true, Result: "已清洗结果"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
before := snapshot(sc)
|
||||||
|
// 只读 handler:读了但没改
|
||||||
|
_ = sc.ToolResults[0].Result
|
||||||
|
diff := diffOnly(before, writable(sc))
|
||||||
|
|
||||||
|
if len(diff) != 0 {
|
||||||
|
t.Fatalf("只读插件应零回传,实际回传 %d 个字段: %v", len(diff), diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 改写插件(如 sanitizer 改 ToolResults)→ 只回传被改的字段。
|
||||||
|
// ⚠️ 这里是原地改切片元素,正是共享底层数组陷阱的触发场景。
|
||||||
|
func TestChangedFieldsOnly_WriterReturnsOnlyChanged(t *testing.T) {
|
||||||
|
sc := &sdk.StageContext{
|
||||||
|
RawMessage: "hello",
|
||||||
|
LLMText: "world",
|
||||||
|
ToolResults: []sdk.ToolResult{
|
||||||
|
{CallID: "c1", Name: "weather_query", Success: true, Result: "带\x1b[31mANSI\x1b[0m脏数据"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
before := snapshot(sc)
|
||||||
|
// sanitizer handler:原地清洗 ToolResults
|
||||||
|
sc.ToolResults[0].Result = "带ANSI脏数据"
|
||||||
|
diff := diffOnly(before, writable(sc))
|
||||||
|
|
||||||
|
if len(diff) != 1 {
|
||||||
|
t.Fatalf("应只回传 tool_results 一个字段,实际 %d 个: %v", len(diff), diff)
|
||||||
|
}
|
||||||
|
if _, ok := diff["tool_results"]; !ok {
|
||||||
|
t.Fatalf("回传字段应为 tool_results,实际 %v", diff)
|
||||||
|
}
|
||||||
|
// raw_message / llm_text 未改,不应出现(否则会覆盖其他插件的改写)
|
||||||
|
if _, ok := diff["raw_message"]; ok {
|
||||||
|
t.Error("raw_message 未改却被回传(会覆盖其他插件的改写)")
|
||||||
|
}
|
||||||
|
if _, ok := diff["llm_text"]; ok {
|
||||||
|
t.Error("llm_text 未改却被回传")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 改写标量字段(如 before_output 改 FinalText)→ 只回传该字段。
|
||||||
|
func TestChangedFieldsOnly_ScalarChange(t *testing.T) {
|
||||||
|
sc := &sdk.StageContext{
|
||||||
|
RawMessage: "hi",
|
||||||
|
FinalText: " 带空白的回复 ",
|
||||||
|
LLMText: "原始",
|
||||||
|
}
|
||||||
|
before := snapshot(sc)
|
||||||
|
sc.FinalText = "带空白的回复"
|
||||||
|
diff := diffOnly(before, writable(sc))
|
||||||
|
|
||||||
|
if len(diff) != 1 || diff["final_text"] != "带空白的回复" {
|
||||||
|
t.Fatalf("应只回传 final_text,实际 %v", diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首次设置 response(短路)→ 回传。
|
||||||
|
func TestChangedFieldsOnly_NewResponseIsReturned(t *testing.T) {
|
||||||
|
sc := &sdk.StageContext{RawMessage: "hi"}
|
||||||
|
before := snapshot(sc)
|
||||||
|
resp := "被插件短路"
|
||||||
|
sc.Response = &resp
|
||||||
|
diff := diffOnly(before, writable(sc))
|
||||||
|
|
||||||
|
if v, ok := diff["response"]; !ok || v != "被插件短路" {
|
||||||
|
t.Fatalf("新设置的 response 应回传,实际 %v", diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空切片字段 → 显式回传空值让内核跟随。
|
||||||
|
func TestChangedFieldsOnly_ClearedSliceIsReturnedAsEmpty(t *testing.T) {
|
||||||
|
sc := &sdk.StageContext{
|
||||||
|
ToolCalls: []sdk.ToolCall{{ID: "t1", Name: "cmd_run"}},
|
||||||
|
}
|
||||||
|
before := snapshot(sc)
|
||||||
|
sc.ToolCalls = nil // 插件拒绝了全部工具调用
|
||||||
|
diff := diffOnly(before, writable(sc))
|
||||||
|
|
||||||
|
v, ok := diff["tool_calls"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("清空 tool_calls 应显式回传空值,实际 %v", diff)
|
||||||
|
}
|
||||||
|
if arr, _ := v.([]sdk.ToolCall); len(arr) != 0 {
|
||||||
|
t.Fatalf("应回传空切片,实际 %v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复刻现网场景(实验 13):sanitizer 清洗后 weather 只读回传,清洗结果不得被覆盖。
|
||||||
|
// 旧实现下 weather 会回传自己收到的旧快照(含脏数据),覆盖 sanitizer 的清洗(丢失率 1.6~4.3%)。
|
||||||
|
func TestChangedFieldsOnly_ProductionScenarioNoOverwrite(t *testing.T) {
|
||||||
|
dirty := "天气:晴 \x1b[31m28°C\x1b[0m"
|
||||||
|
clean := "天气:晴 28°C"
|
||||||
|
|
||||||
|
// 内核下发的原始快照(两插件各拿到一份副本)
|
||||||
|
kernelSnapshot := map[string]interface{}{
|
||||||
|
"raw_message": "查天气",
|
||||||
|
"llm_text": "",
|
||||||
|
"final_text": "",
|
||||||
|
"user_id": "u1",
|
||||||
|
"group_id": "",
|
||||||
|
"phase": "after_toolcall",
|
||||||
|
"no_memory": false,
|
||||||
|
"tool_results": []sdk.ToolResult{{CallID: "c1", Name: "weather_query", Result: dirty}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizer 副本:清洗
|
||||||
|
scSan := &sdk.StageContext{
|
||||||
|
RawMessage: "查天气",
|
||||||
|
UserID: "u1",
|
||||||
|
Phase: sdk.StageAfterToolcall,
|
||||||
|
ToolResults: []sdk.ToolResult{{CallID: "c1", Name: "weather_query", Result: dirty}},
|
||||||
|
}
|
||||||
|
beforeSan := snapshot(scSan)
|
||||||
|
scSan.ToolResults[0].Result = clean
|
||||||
|
diffSan := diffOnly(beforeSan, writable(scSan))
|
||||||
|
|
||||||
|
// weather 副本:只读,不改
|
||||||
|
scWea := &sdk.StageContext{
|
||||||
|
RawMessage: "查天气",
|
||||||
|
UserID: "u1",
|
||||||
|
Phase: sdk.StageAfterToolcall,
|
||||||
|
ToolResults: []sdk.ToolResult{{CallID: "c1", Name: "weather_query", Result: dirty}},
|
||||||
|
}
|
||||||
|
beforeWea := snapshot(scWea)
|
||||||
|
diffWea := diffOnly(beforeWea, writable(scWea))
|
||||||
|
|
||||||
|
// weather 必须零回传,否则它的旧快照会覆盖 sanitizer 的清洗
|
||||||
|
if len(diffWea) != 0 {
|
||||||
|
t.Fatalf("weather 只读却回传 %v —— 会覆盖 sanitizer 清洗结果", diffWea)
|
||||||
|
}
|
||||||
|
// sanitizer 必须回传 tool_results
|
||||||
|
if _, ok := diffSan["tool_results"]; !ok {
|
||||||
|
t.Fatalf("sanitizer 改写了 tool_results 却未回传:%v", diffSan)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内核按 sanitizer → weather 顺序应用 diff(weather 后到,是最坏情形)
|
||||||
|
kernel := map[string]interface{}{}
|
||||||
|
for k, v := range kernelSnapshot {
|
||||||
|
kernel[k] = v
|
||||||
|
}
|
||||||
|
for k, v := range diffSan {
|
||||||
|
kernel[k] = v
|
||||||
|
}
|
||||||
|
for k, v := range diffWea {
|
||||||
|
kernel[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
res, _ := kernel["tool_results"].([]sdk.ToolResult)
|
||||||
|
if len(res) == 0 || res[0].Result != clean {
|
||||||
|
t.Fatalf("清洗结果被覆盖:期望 %q,实际 %v", clean, kernel["tool_results"])
|
||||||
|
}
|
||||||
|
}
|
||||||
521
tools/hmapdev/templates.go
Normal file
521
tools/hmapdev/templates.go
Normal file
@ -0,0 +1,521 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// tmplPlgJSON is the plg.json template
|
||||||
|
const tmplPlgJSON = `{
|
||||||
|
"name": "{{.Plg.Name}}",
|
||||||
|
"name_zh": "{{.Plg.NameZh}}",
|
||||||
|
"name_en": "{{.Plg.NameEn}}",
|
||||||
|
"version": "{{.Plg.Version}}",
|
||||||
|
"description": "{{.Plg.Description}}",
|
||||||
|
"author": "{{.Plg.Author}}",
|
||||||
|
"entry": "{{.Plg.Entry}}",
|
||||||
|
"sdk": "{{.Plg.SDK}}",
|
||||||
|
"tags": [{{range $i, $t := .Plg.Tags}}{{if $i}}, {{end}}"{{$t}}"{{end}}],
|
||||||
|
"targets": "{{.Plg.Targets}}"
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const tmplGoMod = `module {{.ModulePath}}
|
||||||
|
|
||||||
|
go {{.GoVersion}}
|
||||||
|
|
||||||
|
require {{.SDKModule}} {{.SDKVersion}}
|
||||||
|
{{if .SDKLocalPath}}
|
||||||
|
// SDK 指向本机源码。gitcode 的模块不在 proxy.golang.org 上,
|
||||||
|
// 没有这条 replace 就需要 go.sum 条目,而那个条目无处可拉。
|
||||||
|
// 若你已有可访问的私有 proxy,可删掉本行。
|
||||||
|
replace {{.SDKModule}} => {{.SDKLocalPath}}
|
||||||
|
{{end}}`
|
||||||
|
|
||||||
|
const tmplPluginGo = `package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
name string
|
||||||
|
sdk *sdk.PluginSDK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
|
||||||
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
|
p.sdk = s
|
||||||
|
s.RegisterStopHandler(func() { fmt.Printf("[%s] stop handler running\n", p.name) })
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
|
Key: "plugin.{{.Plg.Name}}.example", Default: "hello", Type: "string",
|
||||||
|
DisplayName: "示例配置", Description: "An example configuration key",
|
||||||
|
Category: "{{.Plg.Name}}",
|
||||||
|
})
|
||||||
|
tp := p.name + "_"
|
||||||
|
s.RegisterTool(tp+"hello", sdk.ToolDef{
|
||||||
|
Name: tp + "hello",
|
||||||
|
Description: "A hello world tool",
|
||||||
|
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||||
|
NoMemory: false, // 工具输出对 LLM 注意力有信号价值时为 false,纯操作工具为 true
|
||||||
|
// Cleaner: func(output string) string {
|
||||||
|
// // 工具输出参与向量化/jieba/蒸馏前,在此过滤噪音
|
||||||
|
// return output
|
||||||
|
// },
|
||||||
|
}, p.handleHello)
|
||||||
|
fmt.Printf("[%s] started\n", p.name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Stop() error { fmt.Printf("[%s] stopped\n", p.name); return nil }
|
||||||
|
|
||||||
|
func (p *Plugin) handleHello(args map[string]interface{}) (interface{}, error) {
|
||||||
|
return map[string]interface{}{"content": "Hello from {{.Plg.Name}} plugin!"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return &Plugin{name: name}, nil
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const tmplSDKLua = `-- HomeAgent Lua Plugin SDK (standalone mock)
|
||||||
|
sdk = {}
|
||||||
|
function sdk.log(level, msg) print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg)) end
|
||||||
|
function sdk.register_tool(name, def, handler) print("[lua-plugin] register_tool: " .. tostring(name)) end
|
||||||
|
function sdk.register_stage(stage, handler, scope) print("[lua-plugin] register_stage: " .. tostring(stage) .. " scope=" .. tostring(scope)) end
|
||||||
|
function sdk.register_api(name) print("[lua-plugin] register_api: " .. tostring(name)) end
|
||||||
|
function sdk.register_output_channel(name, caps, desc, def, handler) print("[lua-plugin] register_output_channel: " .. tostring(name)) end
|
||||||
|
function sdk.register_input_channel(name, def) print("[lua-plugin] register_input_channel: " .. tostring(name)) end
|
||||||
|
function sdk.get_setting(key) return nil end
|
||||||
|
function sdk.set_setting(key, value) print("[lua-plugin] set_setting: " .. tostring(key)) end
|
||||||
|
function sdk.inject_text(source, channel, text) print("[lua-plugin] inject_text: " .. tostring(source)) end
|
||||||
|
function sdk.inject_interrupt(source, channel, text) print("[lua-plugin] inject_interrupt: " .. tostring(source)) end
|
||||||
|
function sdk.inject_text_no_memory(source, channel, text) print("[lua-plugin] inject_text_no_memory: " .. tostring(source)) end
|
||||||
|
function sdk.set_auto_restart(enabled) print("[lua-plugin] set_auto_restart: " .. tostring(enabled)) end
|
||||||
|
sdk.memory = {}
|
||||||
|
function sdk.memory.recall(query, depth) return {entities={}, relations={}} end
|
||||||
|
function sdk.memory.commit(triples) return nil end
|
||||||
|
function sdk.memory.introspect() return {} end
|
||||||
|
function sdk.memory.merge(source, target) return 0 end
|
||||||
|
function sdk.memory.purge(criteria, hard) return 0 end
|
||||||
|
sdk.doc = {}
|
||||||
|
function sdk.doc.query(text, top_k) return {} end
|
||||||
|
function sdk.doc.insert(doc) return nil end
|
||||||
|
function sdk.doc.remove(id) return nil end
|
||||||
|
function sdk.doc.stats() return {} end
|
||||||
|
sdk.knowledge = {}
|
||||||
|
function sdk.knowledge.search(query, limit) return {} end
|
||||||
|
function sdk.knowledge.add(tag, content) return nil end
|
||||||
|
function sdk.knowledge.list() return {} end
|
||||||
|
sdk.text_memory = {}
|
||||||
|
function sdk.text_memory.append(evt) return nil end
|
||||||
|
sdk.llm = {}
|
||||||
|
function sdk.llm.list_sources() return {} end
|
||||||
|
function sdk.llm.set_source(name) return nil end
|
||||||
|
function sdk.llm.current_source() return nil end
|
||||||
|
sdk.social = {}
|
||||||
|
function sdk.social.get_person(name) return {} end
|
||||||
|
function sdk.social.get_network(name, depth) return {} end
|
||||||
|
function sdk.social.get_trait(name, trait) return {value=nil, found=false} end
|
||||||
|
function sdk.social.get_relations(name) return {} end
|
||||||
|
function sdk.social.list_persons() return {} end
|
||||||
|
sdk.settings = {}
|
||||||
|
function sdk.settings.get_core(key) return nil end
|
||||||
|
function sdk.settings.set_core(key, value) return nil end
|
||||||
|
function sdk.settings.list_core(prefix) return {} end
|
||||||
|
function sdk.settings.get_plugin(plugin, key) return nil end
|
||||||
|
function sdk.settings.set_plugin(plugin, key, value) return nil end
|
||||||
|
function sdk.settings.list_plugin(plugin, prefix) return {} end
|
||||||
|
function sdk.settings.list(prefix) return {} end
|
||||||
|
function sdk.settings.register_def(def) return nil end
|
||||||
|
function sdk.settings.defs(prefix) return {} end
|
||||||
|
function sdk.settings.dump() return {} end
|
||||||
|
function sdk.settings.plugins() return {} end
|
||||||
|
sdk.json = {}
|
||||||
|
function sdk.json.encode(val)
|
||||||
|
if type(val) == "string" then return '"' .. val:gsub('"', '\\"'):gsub('\n', '\\n') .. '"'
|
||||||
|
elseif type(val) == "number" or type(val) == "boolean" then return tostring(val)
|
||||||
|
elseif type(val) == "table" then local parts, i = {}, 1
|
||||||
|
for k, v in pairs(val) do parts[i] = sdk.json.encode(k) .. ":" .. sdk.json.encode(v); i = i + 1 end
|
||||||
|
return "{" .. table.concat(parts, ",") .. "}" end
|
||||||
|
return "null"
|
||||||
|
end
|
||||||
|
function sdk.json.decode(str) local ok, fn = pcall(load, "return " .. str); if ok then return fn() end; return nil end
|
||||||
|
sdk.http = {}
|
||||||
|
function sdk.http.get(url) print("[lua-plugin] http.get: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end
|
||||||
|
function sdk.http.post(url, body, ct) print("[lua-plugin] http.post: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end
|
||||||
|
return sdk
|
||||||
|
`
|
||||||
|
|
||||||
|
const tmplMainLua = `-- {{.Plg.Name}} plugin
|
||||||
|
local plugin = { name = "{{.Plg.Name}}" }
|
||||||
|
function plugin.start(sdk)
|
||||||
|
sdk.log("info", "{{.Plg.Name}} starting...")
|
||||||
|
sdk.register_tool("{{.Plg.Name}}_hello", {
|
||||||
|
description = "A hello world tool",
|
||||||
|
parameters = { type = "object", properties = {} }
|
||||||
|
}, function(args) return { content = "Hello from {{.Plg.Name}} plugin!" } end)
|
||||||
|
sdk.log("info", "{{.Plg.Name}} started")
|
||||||
|
end
|
||||||
|
function plugin.stop() sdk.log("info", "{{.Plg.Name}} stopped") end
|
||||||
|
return plugin
|
||||||
|
`
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Remote Device Adapter Templates
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const tmplRemoteDeviceMain = `#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
#include "ha_remotedevice.h"
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* {{.Plg.Name}} — Remote Device Adapter
|
||||||
|
*
|
||||||
|
* 声明式远程设备接入示例。
|
||||||
|
* 用户只需实现:
|
||||||
|
* 1. ha_transport_t 的 4 个函数
|
||||||
|
* 2. 声明 handlers 表(设备支持哪些命令 + 对应的处理函数)
|
||||||
|
* 其余协议细节(WS 握手、hello/bind、心跳、重连、命令分发、结果回执)由 SDK 自动处理。
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
/* ====================== 传输层实现 ======================
|
||||||
|
*
|
||||||
|
* 请为你的平台实现以下 4 个函数:
|
||||||
|
* connect(ctx, host, port) — 建立 TCP 连接
|
||||||
|
* send(ctx, data, len) — 发送数据
|
||||||
|
* recv(ctx, buf, len) — 接收数据(阻塞,返回实际接收字节数)
|
||||||
|
* close(ctx) — 关闭连接
|
||||||
|
*
|
||||||
|
* 示例:POSIX socket 实现
|
||||||
|
*/
|
||||||
|
|
||||||
|
#if defined(_WIN32) || defined(_WIN64)
|
||||||
|
/* Windows 平台需包含 winsock2.h */
|
||||||
|
#error "Please implement transport for your platform (see example below)"
|
||||||
|
#else
|
||||||
|
/* POSIX (Linux, macOS, ESP-IDF, Zephyr, etc.) */
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <netinet/in.h>
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <netdb.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
struct transport_ctx {
|
||||||
|
int sock;
|
||||||
|
};
|
||||||
|
|
||||||
|
static int transport_connect(void *ctx, const char *host, uint16_t port) {
|
||||||
|
struct transport_ctx *tc = (struct transport_ctx *)ctx;
|
||||||
|
struct hostent *he = gethostbyname(host);
|
||||||
|
if (!he) return -1;
|
||||||
|
tc->sock = socket(AF_INET, SOCK_STREAM, 0);
|
||||||
|
if (tc->sock < 0) return -1;
|
||||||
|
struct sockaddr_in addr;
|
||||||
|
memset(&addr, 0, sizeof(addr));
|
||||||
|
addr.sin_family = AF_INET;
|
||||||
|
addr.sin_port = htons(port);
|
||||||
|
memcpy(&addr.sin_addr, he->h_addr_list[0], he->h_length);
|
||||||
|
if (connect(tc->sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
|
||||||
|
close(tc->sock);
|
||||||
|
tc->sock = -1;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int transport_send(void *ctx, const uint8_t *data, int len) {
|
||||||
|
struct transport_ctx *tc = (struct transport_ctx *)ctx;
|
||||||
|
int sent = 0;
|
||||||
|
while (sent < len) {
|
||||||
|
int n = (int)send(tc->sock, data + sent, len - sent, 0);
|
||||||
|
if (n <= 0) return -1;
|
||||||
|
sent += n;
|
||||||
|
}
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int transport_recv(void *ctx, uint8_t *buf, int len) {
|
||||||
|
struct transport_ctx *tc = (struct transport_ctx *)ctx;
|
||||||
|
int n = (int)recv(tc->sock, buf, len, 0);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void transport_close(void *ctx) {
|
||||||
|
struct transport_ctx *tc = (struct transport_ctx *)ctx;
|
||||||
|
if (tc->sock >= 0) {
|
||||||
|
close(tc->sock);
|
||||||
|
tc->sock = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* ====================== 声明式命令处理 ======================
|
||||||
|
*
|
||||||
|
* 每个命令对应一个处理函数,通过填写 ha_cmd_result_t 返回数据。
|
||||||
|
* SDK 自动回执结果,无需手动调用 send_result。
|
||||||
|
*
|
||||||
|
* 返回方式:
|
||||||
|
* 1. 文本输出:填写 result->output
|
||||||
|
* 2. 二进制数据:设置 result->has_binary=1 并填写 binary_data/len/mime
|
||||||
|
* 3. 错误:设置 result->status=1 并填写 result->error
|
||||||
|
* 4. 返回 HA_OK 表示处理成功,其他值表示处理失败
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ESP32-CAM 摄像头处理 */
|
||||||
|
static ha_status_t handle_camerasue(const char *req_id, const char *args,
|
||||||
|
ha_cmd_result_t *result, void *userdata) {
|
||||||
|
(void)req_id; (void)userdata;
|
||||||
|
int duration = 0;
|
||||||
|
if (args && args[0]) duration = atoi(args);
|
||||||
|
printf("[camera] %s (duration=%ds)\n", duration ? "record" : "snapshot", duration);
|
||||||
|
|
||||||
|
/* 返回文本结果(base64 图片) */
|
||||||
|
result->status = 0;
|
||||||
|
result->output = "data:image/jpeg;base64,/9j/4AAQ...";
|
||||||
|
return HA_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 屏幕截图处理 */
|
||||||
|
static ha_status_t handle_screensee(const char *req_id, const char *args,
|
||||||
|
ha_cmd_result_t *result, void *userdata) {
|
||||||
|
(void)req_id; (void)args; (void)userdata;
|
||||||
|
printf("[screen] screenshot\n");
|
||||||
|
result->status = 0;
|
||||||
|
result->output = "data:image/png;base64,iVBORw0KGgo...";
|
||||||
|
return HA_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 语音播报处理 */
|
||||||
|
static ha_status_t handle_speakeruse(const char *req_id, const char *args,
|
||||||
|
ha_cmd_result_t *result, void *userdata) {
|
||||||
|
(void)req_id; (void)userdata;
|
||||||
|
printf("[speaker] TTS: %s\n", args ? args : "");
|
||||||
|
result->status = 0;
|
||||||
|
result->output = "speakeruse done";
|
||||||
|
return HA_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 远程操控处理(computeruse) */
|
||||||
|
static ha_status_t handle_computeruse(const char *req_id, const char *args,
|
||||||
|
ha_cmd_result_t *result, void *userdata) {
|
||||||
|
(void)req_id; (void)userdata;
|
||||||
|
const char *action = NULL;
|
||||||
|
const char *json_str = NULL;
|
||||||
|
ha_cmd_parse_json(args, &action, &json_str);
|
||||||
|
printf("[computeruse] action=%s\n", action ? action : "unknown");
|
||||||
|
result->status = 0;
|
||||||
|
result->output = "computeruse done";
|
||||||
|
return HA_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 剪贴板读取 */
|
||||||
|
static ha_status_t handle_clipboardsee(const char *req_id, const char *args,
|
||||||
|
ha_cmd_result_t *result, void *userdata) {
|
||||||
|
(void)req_id; (void)args; (void)userdata;
|
||||||
|
result->status = 0;
|
||||||
|
result->output = "clipboard content";
|
||||||
|
return HA_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 剪贴板写入 */
|
||||||
|
static ha_status_t handle_clipboardsue(const char *req_id, const char *args,
|
||||||
|
ha_cmd_result_t *result, void *userdata) {
|
||||||
|
(void)req_id; (void)userdata;
|
||||||
|
printf("[clipboard] write: %s\n", args ? args : "");
|
||||||
|
result->status = 0;
|
||||||
|
result->output = "clipboard written";
|
||||||
|
return HA_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 屏幕显示 */
|
||||||
|
static ha_status_t handle_screensue(const char *req_id, const char *args,
|
||||||
|
ha_cmd_result_t *result, void *userdata) {
|
||||||
|
(void)req_id; (void)userdata;
|
||||||
|
printf("[screensue] show: %s\n", args ? args : "");
|
||||||
|
result->status = 0;
|
||||||
|
result->output = "screensue shown";
|
||||||
|
return HA_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shell 命令处理 */
|
||||||
|
static ha_status_t handle_shell(const char *req_id, const char *args,
|
||||||
|
ha_cmd_result_t *result, void *userdata) {
|
||||||
|
(void)req_id; (void)userdata;
|
||||||
|
printf("[shell] cmd: %s\n", args ? args : "");
|
||||||
|
result->status = 0;
|
||||||
|
result->output = "shell output";
|
||||||
|
return HA_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 设备信息查询 */
|
||||||
|
static ha_status_t handle_deviceinfo(const char *req_id, const char *args,
|
||||||
|
ha_cmd_result_t *result, void *userdata) {
|
||||||
|
(void)req_id; (void)args; (void)userdata;
|
||||||
|
result->status = 0;
|
||||||
|
result->output = "{\"platform\":\"linux\",\"arch\":\"x86_64\"}";
|
||||||
|
return HA_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====================== 连接状态回调 ====================== */
|
||||||
|
|
||||||
|
static void on_state(int connected, void *userdata) {
|
||||||
|
(void)userdata;
|
||||||
|
printf("[devicelink] state: %s\n", connected ? "connected" : "disconnected");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====================== 主函数 ====================== */
|
||||||
|
|
||||||
|
int main(int argc, char *argv[]) {
|
||||||
|
/* 传输层上下文 */
|
||||||
|
struct transport_ctx tctx;
|
||||||
|
tctx.sock = -1;
|
||||||
|
|
||||||
|
ha_transport_t transport = {
|
||||||
|
.connect = transport_connect,
|
||||||
|
.send = transport_send,
|
||||||
|
.recv = transport_recv,
|
||||||
|
.close = transport_close,
|
||||||
|
.ctx = &tctx,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ===== 声明式设备配置 ===== */
|
||||||
|
|
||||||
|
/* 声明设备能力 */
|
||||||
|
const char *caps[] = {
|
||||||
|
"status", "cmdrun", "deviceinfo",
|
||||||
|
"camerasue", "screensee", "speakeruse",
|
||||||
|
"computeruse", "clipboardsee", "clipboardsue",
|
||||||
|
"screensue",
|
||||||
|
NULL
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 声明命令处理表:设备支持哪些命令,以及对应的处理函数 */
|
||||||
|
ha_cmd_handler_def_t handlers[] = {
|
||||||
|
{.command = "shell", .handler = handle_shell},
|
||||||
|
{.command = "camerasue", .handler = handle_camerasue},
|
||||||
|
{.command = "screensee", .handler = handle_screensee},
|
||||||
|
{.command = "speakeruse", .handler = handle_speakeruse},
|
||||||
|
{.command = "computeruse", .handler = handle_computeruse},
|
||||||
|
{.command = "clipboardsee", .handler = handle_clipboardsee},
|
||||||
|
{.command = "clipboardsue", .handler = handle_clipboardsue},
|
||||||
|
{.command = "screensue", .handler = handle_screensue},
|
||||||
|
{.command = "deviceinfo", .handler = handle_deviceinfo},
|
||||||
|
{.command = NULL}, /* 标记结束 */
|
||||||
|
};
|
||||||
|
|
||||||
|
ha_config_t config = {
|
||||||
|
.transport = transport,
|
||||||
|
.server = "127.0.0.1:9890",
|
||||||
|
.token = "your-token-here",
|
||||||
|
.device = {
|
||||||
|
.device_id = "{{.Plg.Name}}",
|
||||||
|
.name = "{{.Plg.NameEn}}",
|
||||||
|
.kind = "computer",
|
||||||
|
.caps = caps,
|
||||||
|
.info_json = "{\"platform\":\"linux\",\"arch\":\"x86_64\"}",
|
||||||
|
},
|
||||||
|
.handlers = handlers, /* 声明式命令处理表 */
|
||||||
|
.on_state = on_state,
|
||||||
|
.ping_interval = 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
ha_client_t *client = ha_client_new(&config);
|
||||||
|
if (!client) {
|
||||||
|
fprintf(stderr, "Failed to create client\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("Starting remote device adapter: {{.Plg.Name}}\n");
|
||||||
|
printf(" Server: %s\n", config.server);
|
||||||
|
printf(" Device ID: %s\n", config.device.device_id);
|
||||||
|
printf(" Kind: %s\n", config.device.kind);
|
||||||
|
printf(" Caps: ");
|
||||||
|
for (const char **p = caps; *p; p++) printf("%s ", *p);
|
||||||
|
printf("\n");
|
||||||
|
|
||||||
|
ha_status_t st = ha_client_start(client);
|
||||||
|
if (st != HA_OK) {
|
||||||
|
fprintf(stderr, "Failed to connect: %d\n", st);
|
||||||
|
ha_client_destroy(client);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("Connected! Entering main loop...\n");
|
||||||
|
|
||||||
|
/* 主循环 */
|
||||||
|
while (1) {
|
||||||
|
ha_status_t st = ha_client_process(client);
|
||||||
|
if (st == HA_ERR_DISCONNECTED) {
|
||||||
|
printf("Disconnected, exiting.\n");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
#if defined(_WIN32) || defined(_WIN64)
|
||||||
|
Sleep(10);
|
||||||
|
#else
|
||||||
|
usleep(10000);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
ha_client_stop(client);
|
||||||
|
ha_client_destroy(client);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const tmplRemoteDeviceCMake = `cmake_minimum_required(VERSION 3.10)
|
||||||
|
project({{.Plg.Name}} VERSION 0.1.0 LANGUAGES C)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# {{.Plg.Name}} — Remote Device Adapter
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# 设置 SDK 路径(默认使用内置 SDK,也可通过 -DSDK_PATH=... 指定)
|
||||||
|
set(SDK_PATH "${CMAKE_CURRENT_SOURCE_DIR}/ha_remotedevice"
|
||||||
|
CACHE PATH "Path to ha_remotedevice SDK")
|
||||||
|
|
||||||
|
# 添加 SDK 子目录
|
||||||
|
if(EXISTS "${SDK_PATH}/CMakeLists.txt")
|
||||||
|
add_subdirectory(${SDK_PATH} ha_remotedevice)
|
||||||
|
else()
|
||||||
|
message(FATAL_ERROR "ha_remotedevice SDK not found at ${SDK_PATH}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# 创建设备适配器可执行文件
|
||||||
|
add_executable(${PROJECT_NAME}
|
||||||
|
main.c
|
||||||
|
)
|
||||||
|
|
||||||
|
# 链接 SDK
|
||||||
|
target_link_libraries(${PROJECT_NAME} PRIVATE ha_remotedevice)
|
||||||
|
|
||||||
|
# 包含 SDK 头文件
|
||||||
|
target_include_directories(${PROJECT_NAME} PRIVATE
|
||||||
|
${HA_REMOTEDEVICE_INCLUDE_DIR}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 编译选项
|
||||||
|
if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
|
||||||
|
target_compile_options(${PROJECT_NAME} PRIVATE
|
||||||
|
-Wall -Wextra -Wpedantic
|
||||||
|
-Wno-unused-parameter
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# 安装
|
||||||
|
install(TARGETS ${PROJECT_NAME} RUNTIME DESTINATION bin)
|
||||||
|
`
|
||||||
|
|
||||||
|
const tmplReadme = `# {{.Plg.Name}}
|
||||||
|
|
||||||
|
{{.Plg.Description}}
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
` + "```bash" + `
|
||||||
|
hmapdev build
|
||||||
|
` + "```" + `
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Upload the .hmap file through the Plugin Manager API.
|
||||||
|
`
|
||||||
1767
tools/hmapdev/templates/proc_main.go.tmpl
Normal file
1767
tools/hmapdev/templates/proc_main.go.tmpl
Normal file
File diff suppressed because it is too large
Load Diff
54
tools/hmapdev/templates/proc_shm_unix.go.tmpl
Normal file
54
tools/hmapdev/templates/proc_shm_unix.go.tmpl
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
//go:build linux || darwin || freebsd
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Unix 侧共享段挂载:内核经 ExtraFiles 传入继承的 fd。
|
||||||
|
//
|
||||||
|
// 统一共享内存区域布局(§13.1):
|
||||||
|
//
|
||||||
|
// fd 3 = 统一区域(SuperBlock + StageContext + EvtRing)
|
||||||
|
// fd 4 = 事件通知(Linux eventfd / macOS pipe 读端)
|
||||||
|
//
|
||||||
|
// 继承的 fd 无需文件名,也不残留——这是选 memfd 而非 /dev/shm 的原因。
|
||||||
|
const (
|
||||||
|
fdUnifiedShm = 3
|
||||||
|
fdEvtNotifier = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// attachUnifiedShm 挂载统一共享内存区域。
|
||||||
|
//
|
||||||
|
// 各进程 mmap 到不同虚拟地址,段内一律用相对偏移而非指针,故仍能正确解引用
|
||||||
|
// (实验 2 已验证父子 mmap 基址不同时偏移解引用正确)。
|
||||||
|
func attachUnifiedShm(size int) ([]byte, error) {
|
||||||
|
return syscall.Mmap(fdUnifiedShm, 0, size,
|
||||||
|
syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_SHARED)
|
||||||
|
}
|
||||||
|
|
||||||
|
// openEvtNotifier 打开事件通知读端。
|
||||||
|
func openEvtNotifier() (evtWaiter, error) {
|
||||||
|
f := os.NewFile(fdEvtNotifier, "evtnotify")
|
||||||
|
if f == nil {
|
||||||
|
return nil, fmt.Errorf("fd %d 不是有效的通知句柄", fdEvtNotifier)
|
||||||
|
}
|
||||||
|
return &unixEvtWaiter{f: f}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unixEvtWaiter 用 eventfd/pipe 的阻塞 Read 等待通知。
|
||||||
|
//
|
||||||
|
// os.NewFile 把 fd 注册进 runtime netpoller,Read 阻塞时只 park goroutine,
|
||||||
|
// 不占 OS 线程(实验 1:200 个等待者仅增 1 个 OS 线程)。
|
||||||
|
// 反面对照是经 cgo 调 sem_wait——那会阻塞整个 M。
|
||||||
|
type unixEvtWaiter struct {
|
||||||
|
f *os.File
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *unixEvtWaiter) Wait(buf []byte) error {
|
||||||
|
_, err := w.f.Read(buf)
|
||||||
|
return err
|
||||||
|
}
|
||||||
153
tools/hmapdev/templates/proc_shm_windows.go.tmpl
Normal file
153
tools/hmapdev/templates/proc_shm_windows.go.tmpl
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Windows 侧共享段挂载:走命名对象而非继承 fd。
|
||||||
|
//
|
||||||
|
// 为何不能照抄 Unix:Windows 没有 fd 继承语义,`ExtraFiles` 在 os/exec 的
|
||||||
|
// Windows 实现里不被支持。等价机制是命名内核对象——父进程用
|
||||||
|
// CreateFileMapping / CreateEvent 建带名字的对象,子进程按同名 Open 拿到同一对象。
|
||||||
|
//
|
||||||
|
// 名字经环境变量传入(内核 internal/plugin/proc/plugin_windows.go 设置),
|
||||||
|
// 而不是硬编码:多个 homed 实例并存时不能撞名。
|
||||||
|
//
|
||||||
|
// **这是 §9.2 的正解**:C ABI 时代 Windows 是第三套独立 ABI 实现,
|
||||||
|
// stage 只下发 3 个字段且完全没有写回,sanitizer 这类改写型插件静默失效。
|
||||||
|
// 现在 Windows 与 Unix 共用同一份 RPC 逻辑与同一份共享段布局,
|
||||||
|
// 差异被收敛到本文件的三个函数里。
|
||||||
|
const (
|
||||||
|
envStageShmName = "HOMEAGENT_SHM_STAGE"
|
||||||
|
envEvtRingName = "HOMEAGENT_SHM_EVTRING"
|
||||||
|
envEvtEventName = "HOMEAGENT_EVT_EVENT"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Windows API 绑定:用 LazyDLL 而非 golang.org/x/sys/windows。
|
||||||
|
//
|
||||||
|
// 原因:OpenFileMappingW / OpenEventW 未被标准库 syscall 包导出。
|
||||||
|
// 引入 x/sys 会给**每个插件的 go.mod 加一个新依赖**,
|
||||||
|
// 而「外部插件零改动」是本次迁移的硬约束(插件仅依赖公开 SDK)。
|
||||||
|
// LazyDLL 属于标准库 syscall,零新增依赖。
|
||||||
|
var (
|
||||||
|
kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||||
|
procOpenFileMappingW = kernel32.NewProc("OpenFileMappingW")
|
||||||
|
procOpenEventW = kernel32.NewProc("OpenEventW")
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
winEventModifyState = 0x0002
|
||||||
|
winSynchronize = 0x00100000
|
||||||
|
)
|
||||||
|
|
||||||
|
// openFileMappingW 封装 OpenFileMappingW。
|
||||||
|
func openFileMappingW(access uint32, inherit bool, name *uint16) (syscall.Handle, error) {
|
||||||
|
var inheritFlag uintptr
|
||||||
|
if inherit {
|
||||||
|
inheritFlag = 1
|
||||||
|
}
|
||||||
|
r, _, err := procOpenFileMappingW.Call(
|
||||||
|
uintptr(access), inheritFlag, uintptr(unsafe.Pointer(name)))
|
||||||
|
if r == 0 {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return syscall.Handle(r), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openEventW 封装 OpenEventW。
|
||||||
|
func openEventW(access uint32, inherit bool, name *uint16) (syscall.Handle, error) {
|
||||||
|
var inheritFlag uintptr
|
||||||
|
if inherit {
|
||||||
|
inheritFlag = 1
|
||||||
|
}
|
||||||
|
r, _, err := procOpenEventW.Call(
|
||||||
|
uintptr(access), inheritFlag, uintptr(unsafe.Pointer(name)))
|
||||||
|
if r == 0 {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return syscall.Handle(r), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachStageShm 按名字打开 StageContext 段并映射。
|
||||||
|
func attachStageShm(size int) ([]byte, error) {
|
||||||
|
return openNamedMapping(os.Getenv(envStageShmName), size, "StageContext 段")
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachEvtRingShm 按名字打开事件环段并映射。
|
||||||
|
func attachEvtRingShm(size int) ([]byte, error) {
|
||||||
|
return openNamedMapping(os.Getenv(envEvtRingName), size, "事件环段")
|
||||||
|
}
|
||||||
|
|
||||||
|
// openNamedMapping 打开命名共享段并映射为 []byte。
|
||||||
|
//
|
||||||
|
// 与 Unix 的 mmap 语义对齐:MapViewOfFile 返回的地址在本进程虚拟空间,
|
||||||
|
// 段内偏移仍是相对的,故跨进程解引用正确。
|
||||||
|
func openNamedMapping(name string, size int, what string) ([]byte, error) {
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("%s 名字未经环境变量传入", what)
|
||||||
|
}
|
||||||
|
namePtr, err := syscall.UTF16PtrFromString(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s 名字非法: %w", what, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h, err := openFileMappingW(syscall.FILE_MAP_WRITE, false, namePtr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("打开 %s(%s): %w", what, name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
addr, err := syscall.MapViewOfFile(h, syscall.FILE_MAP_WRITE, 0, 0, uintptr(size))
|
||||||
|
if err != nil {
|
||||||
|
syscall.CloseHandle(h)
|
||||||
|
return nil, fmt.Errorf("映射 %s: %w", what, err)
|
||||||
|
}
|
||||||
|
// 句柄不关:视图存活期间必须保持句柄有效,进程退出时由 OS 回收。
|
||||||
|
|
||||||
|
return unsafe.Slice((*byte)(unsafe.Pointer(addr)), size), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openEvtNotifier 按名字打开事件通知对象。
|
||||||
|
func openEvtNotifier() (evtWaiter, error) {
|
||||||
|
name := os.Getenv(envEvtEventName)
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("事件通知对象名字未经环境变量传入")
|
||||||
|
}
|
||||||
|
namePtr, err := syscall.UTF16PtrFromString(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("事件对象名字非法: %w", err)
|
||||||
|
}
|
||||||
|
h, err := openEventW(winSynchronize|winEventModifyState, false, namePtr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("打开事件对象(%s): %w", name, err)
|
||||||
|
}
|
||||||
|
return &windowsEvtWaiter{h: h}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// windowsEvtWaiter 用命名 Event 对象等待通知。
|
||||||
|
//
|
||||||
|
// 与 eventfd 的差异:Event 是二元信号而非计数器,多次 SetEvent 只对应
|
||||||
|
// 一次唤醒。这不影响正确性——消费者被唤醒后按 readSeq 追 writeSeq
|
||||||
|
// 批量 drain,一次唤醒能处理累积的全部事件。
|
||||||
|
//
|
||||||
|
// WaitForSingleObject 阻塞的是 OS 线程而非仅 goroutine,故不如 eventfd
|
||||||
|
// 的 netpoller 路径省线程。每插件一个消费 goroutine,17 插件即 17 线程,
|
||||||
|
// 在可接受范围(实验 5 实测 17 子进程共 84 线程)。
|
||||||
|
type windowsEvtWaiter struct {
|
||||||
|
h syscall.Handle
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *windowsEvtWaiter) Wait(buf []byte) error {
|
||||||
|
ev, err := syscall.WaitForSingleObject(w.h, syscall.INFINITE)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ev != syscall.WAIT_OBJECT_0 {
|
||||||
|
return fmt.Errorf("等待事件对象返回 0x%x", ev)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -13,7 +13,7 @@ import (
|
|||||||
|
|
||||||
"github.com/traefik/yaegi/interp"
|
"github.com/traefik/yaegi/interp"
|
||||||
"github.com/traefik/yaegi/stdlib"
|
"github.com/traefik/yaegi/stdlib"
|
||||||
"github.com/JianFeeeee/homeagent-sdk/tools/plugindev/yaegi/mocksdk"
|
"gitcode.com/JianFeeeee/homeagent-sdk/tools/hmapdev/yaegi/mocksdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
type YaegiDebugger struct {
|
type YaegiDebugger struct {
|
||||||
@ -83,18 +83,69 @@ type ToolResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ToolDef struct {
|
type ToolDef struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Plugin string `json:"plugin,omitempty"`
|
Plugin string `json:"plugin,omitempty"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Parameters map[string]interface{} `json:"parameters"`
|
Parameters map[string]interface{} `json:"parameters"`
|
||||||
NoMemory bool `json:"no_memory,omitempty"`
|
NoMemory bool `json:"no_memory,omitempty"`
|
||||||
Cleaner func(string) string `json:"-"`
|
Cleaner func(string) string `json:"-"`
|
||||||
|
ContextPolicy string `json:"context_policy,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上下文策略取值,与公共 SDK 一致。
|
||||||
|
const (
|
||||||
|
ContextPolicyNone = "none"
|
||||||
|
ContextPolicyPrune = "prune"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InjectOptions 与公共 SDK 同构:声明一次注入是否记入记忆、是否据此裁剪上下文、
|
||||||
|
// 以及用哪个已注册的通道 cleaner 清洗注入内容。
|
||||||
|
type InjectOptions struct {
|
||||||
|
NoMemory bool
|
||||||
|
ContextPolicy string
|
||||||
|
CleanerName string
|
||||||
}
|
}
|
||||||
|
|
||||||
type IOInjector interface {
|
type IOInjector interface {
|
||||||
InjectInterruptText(source, channel, text string)
|
InjectInterruptText(source, channel, text string)
|
||||||
InjectText(source, channel, text string)
|
InjectText(source, channel, text string)
|
||||||
InjectTextNoMemory(source, channel, text string)
|
InjectTextNoMemory(source, channel, text string)
|
||||||
|
// InjectInputSync 注入输入事件并同步等待 agent 回复(无回复时返回空串)。
|
||||||
|
// 通道类插件(qq / a2a 等)靠它完成「收到入站 → agent 处理 → 回复取回」闭环,
|
||||||
|
// 而 mock 此前只有带 flags 的 InjectInputSyncOpts、没有这个零值糖——
|
||||||
|
// 于是一个能在 plugin.bin 里编译通过、在 yaegi 下却调不通的方法就长住了。
|
||||||
|
InjectInputSync(source, channel, text string) string
|
||||||
|
// 1.1.0 媒体注入。与公共 SDK 同构:插件在 yaegi 下调得通的方法,
|
||||||
|
// 编成 plugin.bin 后必须也调得通,否则调试期与真实运行行为不一致。
|
||||||
|
InjectInputMedia(source, channel, text string, blocks []ContentBlock)
|
||||||
|
InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
|
||||||
|
InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
|
||||||
|
SetToolBlocks(blocks []ContentBlock)
|
||||||
|
|
||||||
|
// 1.2.0 带标志位的注入,与公共 SDK 同构。
|
||||||
|
InjectTextOpts(source, channel, text string, opts InjectOptions)
|
||||||
|
InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
|
||||||
|
InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
|
||||||
|
InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||||
|
InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
|
||||||
|
InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContentBlock 与公共 SDK 同构(OpenAI 多模态内容块格式)。
|
||||||
|
type ContentBlock struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
ImageURL *ImageURL `json:"image_url,omitempty"`
|
||||||
|
AudioURL *AudioURL `json:"audio_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageURL struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AudioURL struct {
|
||||||
|
URL string `json:"url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type EventType string
|
type EventType string
|
||||||
@ -159,29 +210,33 @@ type mockSettings struct{ data map[string]interface{} }
|
|||||||
|
|
||||||
func (s *mockSettings) Get(key string) (interface{}, error) {
|
func (s *mockSettings) Get(key string) (interface{}, error) {
|
||||||
v, ok := s.data[key]
|
v, ok := s.data[key]
|
||||||
if !ok { return nil, nil }
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
func (s *mockSettings) Set(key string, value interface{}) error { s.data[key] = value; return nil }
|
func (s *mockSettings) Set(key string, value interface{}) error { s.data[key] = value; return nil }
|
||||||
func (s *mockSettings) List(prefix string) ([]string, error) {
|
func (s *mockSettings) List(prefix string) ([]string, error) {
|
||||||
var ks []string
|
var ks []string
|
||||||
for k := range s.data {
|
for k := range s.data {
|
||||||
if strings.HasPrefix(k, prefix) { ks = append(ks, k) }
|
if strings.HasPrefix(k, prefix) {
|
||||||
|
ks = append(ks, k)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return ks, nil
|
return ks, nil
|
||||||
}
|
}
|
||||||
func (s *mockSettings) GetCore(key string) (interface{}, error) { return nil, nil }
|
func (s *mockSettings) GetCore(key string) (interface{}, error) { return nil, nil }
|
||||||
func (s *mockSettings) SetCore(key string, value interface{}) error { return nil }
|
func (s *mockSettings) SetCore(key string, value interface{}) error { return nil }
|
||||||
func (s *mockSettings) ListCore(prefix string) ([]string, error) { return nil, nil }
|
func (s *mockSettings) ListCore(prefix string) ([]string, error) { return nil, nil }
|
||||||
func (s *mockSettings) GetPlugin(p, k string) (interface{}, error) { return nil, nil }
|
func (s *mockSettings) GetPlugin(p, k string) (interface{}, error) { return nil, nil }
|
||||||
func (s *mockSettings) SetPlugin(p, k string, v interface{}) error { return nil }
|
func (s *mockSettings) SetPlugin(p, k string, v interface{}) error { return nil }
|
||||||
func (s *mockSettings) ListPlugin(p, prefix string) ([]string, error) { return nil, nil }
|
func (s *mockSettings) ListPlugin(p, prefix string) ([]string, error) { return nil, nil }
|
||||||
func (s *mockSettings) RegisterDef(def ConfigDef) {
|
func (s *mockSettings) RegisterDef(def ConfigDef) {
|
||||||
logf("config def: %s = %s", def.Key, def.Default)
|
logf("config def: %s = %s", def.Key, def.Default)
|
||||||
}
|
}
|
||||||
func (s *mockSettings) Defs(prefix string) []*ConfigDef { return nil }
|
func (s *mockSettings) Defs(prefix string) []*ConfigDef { return nil }
|
||||||
func (s *mockSettings) Dump() map[string]interface{} { return s.data }
|
func (s *mockSettings) Dump() map[string]interface{} { return s.data }
|
||||||
func (s *mockSettings) Plugins() []string { return nil }
|
func (s *mockSettings) Plugins() []string { return nil }
|
||||||
|
|
||||||
type Entity struct {
|
type Entity struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@ -195,10 +250,21 @@ type Relation struct {
|
|||||||
Object string `json:"object"`
|
Object string `json:"object"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Triple 与公共 SDK 同构。
|
||||||
|
//
|
||||||
|
// ❗字段名曾是 `Predicate`,而公共 SDK 一直叫 `Relation`。
|
||||||
|
// yaegi 解释器下插件写 `Relation:` 会报未知字段,写 `Predicate:` 则在
|
||||||
|
// 编成 plugin.bin 时报错——谁都不对。没人发现是因为没有任何代码
|
||||||
|
// 对着 mocksdk 编译,漂移不会被编译器抓到。
|
||||||
type Triple struct {
|
type Triple struct {
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
Predicate string `json:"predicate"`
|
Relation string `json:"relation"`
|
||||||
Object string `json:"object"`
|
Object string `json:"object"`
|
||||||
|
Confidence float64 `json:"confidence,omitempty"`
|
||||||
|
SubjectType string `json:"subject_type,omitempty"`
|
||||||
|
ObjectType string `json:"object_type,omitempty"`
|
||||||
|
SentenceText string `json:"sentence_text,omitempty"`
|
||||||
|
MediaDigests []string `json:"media_digests,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MemoryAPI interface {
|
type MemoryAPI interface {
|
||||||
@ -212,37 +278,57 @@ type MemoryAPI interface {
|
|||||||
type mockMemory struct{}
|
type mockMemory struct{}
|
||||||
|
|
||||||
func (mockMemory) Recall(q []string, d int) ([]Entity, []Relation, error) { return nil, nil, nil }
|
func (mockMemory) Recall(q []string, d int) ([]Entity, []Relation, error) { return nil, nil, nil }
|
||||||
func (mockMemory) Commit(t []Triple) error { return nil }
|
func (mockMemory) Commit(t []Triple) error { return nil }
|
||||||
func (mockMemory) Introspect() (map[string]interface{}, error) { return map[string]interface{}{}, nil }
|
func (mockMemory) Introspect() (map[string]interface{}, error) { return map[string]interface{}{}, nil }
|
||||||
func (mockMemory) MergeEntities(s, t string) (int, error) { return 0, nil }
|
func (mockMemory) MergeEntities(s, t string) (int, error) { return 0, nil }
|
||||||
func (mockMemory) Purge(c map[string]string, m string) (int, error) { return 0, nil }
|
func (mockMemory) Purge(c map[string]string, m string) (int, error) { return 0, nil }
|
||||||
|
|
||||||
type Doc struct {
|
type Doc struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
|
// 1.1.0:媒体字段。与公共 SDK 保持同构,否则插件在 yaegi 下跑得通、
|
||||||
|
// 编成 plugin.bin 却编不过(或反之)。
|
||||||
|
MediaDigests []string `json:"media_digests,omitempty"`
|
||||||
|
Attachments []MediaAttachment `json:"attachments,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MediaAttachment 与公共 SDK 同构:写入时给 Data+MIME,引用已有内容时只给 Digest。
|
||||||
|
type MediaAttachment struct {
|
||||||
|
Digest string `json:"digest,omitempty"`
|
||||||
|
MIME string `json:"mime,omitempty"`
|
||||||
|
Data []byte `json:"data,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DocMemoryAPI interface {
|
type DocMemoryAPI interface {
|
||||||
Query(text string, topK int) []*Doc
|
Query(text string, topK int) []*Doc
|
||||||
Insert(doc *Doc) error
|
Insert(doc *Doc) error
|
||||||
|
InsertWithMedia(doc *Doc, attachments []MediaAttachment) error
|
||||||
Remove(id string)
|
Remove(id string)
|
||||||
Stats() map[string]interface{}
|
Stats() map[string]interface{}
|
||||||
}
|
}
|
||||||
|
|
||||||
type mockDocMemory struct{}
|
type mockDocMemory struct{}
|
||||||
|
|
||||||
func (mockDocMemory) Query(t string, k int) []*Doc { return nil }
|
func (mockDocMemory) Query(t string, k int) []*Doc { return nil }
|
||||||
func (mockDocMemory) Insert(doc *Doc) error { return nil }
|
func (mockDocMemory) Insert(doc *Doc) error { return nil }
|
||||||
func (mockDocMemory) Remove(id string) {}
|
func (mockDocMemory) InsertWithMedia(doc *Doc, atts []MediaAttachment) error {
|
||||||
func (mockDocMemory) Stats() map[string]interface{} { return nil }
|
logf("doc_insert_with_media: %d 份附件", len(atts))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (mockDocMemory) Remove(id string) {}
|
||||||
|
func (mockDocMemory) Stats() map[string]interface{} { return nil }
|
||||||
|
|
||||||
type TextEvent struct {
|
type TextEvent struct {
|
||||||
Timestamp int64 `json:"timestamp"`
|
Timestamp int64 `json:"timestamp"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
|
// 1.1.0:附件。读回时内核从正文标记反解,写入时内核把标记并进正文。
|
||||||
|
Attachments []MediaAttachment `json:"attachments,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TextMemoryAPI interface {
|
type TextMemoryAPI interface {
|
||||||
@ -305,8 +391,8 @@ type LLMAPI interface {
|
|||||||
type mockLLM struct{}
|
type mockLLM struct{}
|
||||||
|
|
||||||
func (mockLLM) ListSources() []string { return nil }
|
func (mockLLM) ListSources() []string { return nil }
|
||||||
func (mockLLM) SetSource(n string) error { return nil }
|
func (mockLLM) SetSource(n string) error { return nil }
|
||||||
func (mockLLM) CurrentSource() string { return "" }
|
func (mockLLM) CurrentSource() string { return "" }
|
||||||
|
|
||||||
type IOInjectorImpl struct{}
|
type IOInjectorImpl struct{}
|
||||||
|
|
||||||
@ -319,27 +405,68 @@ func (IOInjectorImpl) InjectText(source, channel, text string) {
|
|||||||
func (IOInjectorImpl) InjectTextNoMemory(source, channel, text string) {
|
func (IOInjectorImpl) InjectTextNoMemory(source, channel, text string) {
|
||||||
logf("inject_text_no_memory: source=%s channel=%s", source, channel)
|
logf("inject_text_no_memory: source=%s channel=%s", source, channel)
|
||||||
}
|
}
|
||||||
|
func (IOInjectorImpl) InjectInputMedia(source, channel, text string, blocks []ContentBlock) {
|
||||||
|
logf("inject_input_media: source=%s channel=%s blocks=%d", source, channel, len(blocks))
|
||||||
|
}
|
||||||
|
func (IOInjectorImpl) InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string {
|
||||||
|
logf("inject_input_media_sync: source=%s channel=%s blocks=%d", source, channel, len(blocks))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
func (IOInjectorImpl) InjectInterruptMedia(source, channel, text string, blocks []ContentBlock) {
|
||||||
|
logf("inject_interrupt_media: source=%s channel=%s blocks=%d", source, channel, len(blocks))
|
||||||
|
}
|
||||||
|
func (IOInjectorImpl) SetToolBlocks(blocks []ContentBlock) {
|
||||||
|
logf("set_tool_blocks: blocks=%d", len(blocks))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 带 InjectOptions 的注入 ----
|
||||||
|
|
||||||
|
func (IOInjectorImpl) InjectInputSync(source, channel, text string) string {
|
||||||
|
logf("inject_sync: source=%s channel=%s", source, channel)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (IOInjectorImpl) InjectTextOpts(source, channel, text string, opts InjectOptions) {
|
||||||
|
logf("inject_text_opts: source=%s channel=%s no_memory=%v policy=%s", source, channel, opts.NoMemory, opts.ContextPolicy)
|
||||||
|
}
|
||||||
|
func (IOInjectorImpl) InjectInterruptTextOpts(source, channel, text string, opts InjectOptions) {
|
||||||
|
logf("inject_interrupt_opts: source=%s channel=%s no_memory=%v policy=%s", source, channel, opts.NoMemory, opts.ContextPolicy)
|
||||||
|
}
|
||||||
|
func (IOInjectorImpl) InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string {
|
||||||
|
logf("inject_sync_opts: source=%s channel=%s no_memory=%v policy=%s", source, channel, opts.NoMemory, opts.ContextPolicy)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
func (IOInjectorImpl) InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) {
|
||||||
|
logf("inject_input_media_opts: source=%s channel=%s blocks=%d no_memory=%v policy=%s", source, channel, len(blocks), opts.NoMemory, opts.ContextPolicy)
|
||||||
|
}
|
||||||
|
func (IOInjectorImpl) InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string {
|
||||||
|
logf("inject_input_media_sync_opts: source=%s channel=%s blocks=%d no_memory=%v policy=%s", source, channel, len(blocks), opts.NoMemory, opts.ContextPolicy)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
func (IOInjectorImpl) InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) {
|
||||||
|
logf("inject_interrupt_media_opts: source=%s channel=%s blocks=%d no_memory=%v policy=%s", source, channel, len(blocks), opts.NoMemory, opts.ContextPolicy)
|
||||||
|
}
|
||||||
|
|
||||||
type PluginSDK struct {
|
type PluginSDK struct {
|
||||||
Name string
|
Name string
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
toolDefs map[string]ToolDef
|
toolDefs map[string]ToolDef
|
||||||
toolHandlers map[string]ToolHandler
|
toolHandlers map[string]ToolHandler
|
||||||
stageHandlers map[string]StageHandler
|
stageHandlers map[string]StageHandler
|
||||||
outChannels map[string]ToolHandler
|
outChannels map[string]ToolHandler
|
||||||
Settings SettingsAPI
|
Settings SettingsAPI
|
||||||
IO IOInjector
|
IO IOInjector
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(name string) *PluginSDK {
|
func New(name string) *PluginSDK {
|
||||||
return &PluginSDK{
|
return &PluginSDK{
|
||||||
Name: name,
|
Name: name,
|
||||||
toolDefs: make(map[string]ToolDef),
|
toolDefs: make(map[string]ToolDef),
|
||||||
toolHandlers: make(map[string]ToolHandler),
|
toolHandlers: make(map[string]ToolHandler),
|
||||||
stageHandlers: make(map[string]StageHandler),
|
stageHandlers: make(map[string]StageHandler),
|
||||||
outChannels: make(map[string]ToolHandler),
|
outChannels: make(map[string]ToolHandler),
|
||||||
Settings: &mockSettings{data: map[string]interface{}{}},
|
Settings: &mockSettings{data: map[string]interface{}{}},
|
||||||
IO: IOInjectorImpl{},
|
IO: IOInjectorImpl{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
if len(os.Args) < 2 {
|
|
||||||
help()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch os.Args[1] {
|
|
||||||
case "init":
|
|
||||||
cmdInit(os.Args[2:])
|
|
||||||
case "build":
|
|
||||||
cmdBuild(os.Args[2:])
|
|
||||||
case "clean":
|
|
||||||
cmdClean(os.Args[2:])
|
|
||||||
case "debug":
|
|
||||||
cmdDebug(os.Args[2:])
|
|
||||||
case "sdk":
|
|
||||||
cmdSDK(os.Args[2:])
|
|
||||||
default:
|
|
||||||
help()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func help() {
|
|
||||||
fmt.Print(`HomeAgent Plugin Dev Tool
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
plugindev init <name> Scaffold a new plugin project
|
|
||||||
plugindev init <name> --lua Create Lua plugin
|
|
||||||
plugindev init <name> --type remotedevice
|
|
||||||
Create C remote device adapter
|
|
||||||
plugindev build [flags] Compile and package plugin
|
|
||||||
plugindev clean Clean build/dist artifacts
|
|
||||||
plugindev debug [dir] Interpret and debug plugin source
|
|
||||||
plugindev sdk <command> Manage SDK versions
|
|
||||||
|
|
||||||
Flags:
|
|
||||||
--outdir Output directory (default: dist)
|
|
||||||
--target Target OS/arch (e.g. linux/amd64), repeatable
|
|
||||||
--lua Create Lua plugin (for init)
|
|
||||||
--type Project type: "remotedevice" (for init)
|
|
||||||
-t Alias for --type
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
3
tools/vscode-hmapdev/.gitignore
vendored
Normal file
3
tools/vscode-hmapdev/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
out/
|
||||||
|
*.vsix
|
||||||
5
tools/vscode-hmapdev/.vscodeignore
Normal file
5
tools/vscode-hmapdev/.vscodeignore
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
src/
|
||||||
|
tsconfig.json
|
||||||
|
node_modules/
|
||||||
|
out/test/
|
||||||
|
.gitignore
|
||||||
69
tools/vscode-hmapdev/README.md
Normal file
69
tools/vscode-hmapdev/README.md
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# hmapdev — HomeAgent 插件开发 VSCode 扩展
|
||||||
|
|
||||||
|
调试与构建 HomeAgent 插件工程的 IDE 支持:**plg.json 校验、SDK 版本解析、构建/运行、内核日志跟随**。
|
||||||
|
|
||||||
|
## 为什么需要它
|
||||||
|
|
||||||
|
插件的真实形态是「**独立子进程 + 内核侧握手**」,所以插件的三类问题几乎都在 IDE 之外发生:
|
||||||
|
|
||||||
|
1. **编不出来** —— 最常见的原因不是代码,而是项目没声明要用哪版 SDK,工具链拿了存储里的
|
||||||
|
`current`(可能是陈旧的 `v0.8.0`),于是报一堆看不懂的 `undefined: sdk.XXX`;
|
||||||
|
2. **编出来但起不来** —— 产物与内核**协议绑定**(协议版本 + 共享内存魔数),用错工具链编出来的
|
||||||
|
插件会在握手时被拒;
|
||||||
|
3. **起来了但行为不对** —— 真因往往只在内核日志里(建链失败、崩溃重启、工具报错)。
|
||||||
|
|
||||||
|
本扩展把这三件事拉进 IDE:**先把「用哪版 SDK」摆到明面上**,再让构建/运行/看日志变成一条动作链。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
| 功能 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| **plg.json 诊断** | 必需字段;`sdk` 必须是**完整版本号**(区间写法 `1.2` 会报错并说明「patch 位恒为 .0」);声明的 SDK 若未安装在本地存储,直接给出 `hmapdev sdk install vX.Y.Z` |
|
||||||
|
| **状态栏** | `插件 · SDK <声明> · hmapdev <版本>`;工具链缺失或工程有错时变红/黄,tooltip 列出已装 SDK |
|
||||||
|
| **构建 / 清理 / 运行** | `hmapdev build`、`build --target all`、`clean`、`debug`(解释执行,快速迭代)——在集成终端里跑,可 Ctrl-C |
|
||||||
|
| **任务(Tasks)** | 同一批动作注册为 `hmapdev` 任务,可绑快捷键、串依赖;带 **Go 问题匹配器**,编译错误进 Problems 面板 |
|
||||||
|
| **跟随内核日志** | 读 `<dataDir>/log` 下最新的 `homed_*.log`,按插件名过滤后持续输出(真正的联调回路) |
|
||||||
|
| **SDK 管理** | 查看工具链版本、列出/安装/切换 SDK 版本(走 QuickPick,不用记命令) |
|
||||||
|
| **JSON 支持** | `plg.json` 的 schema 校验 + 骨架片段 |
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/vscode-hmapdev
|
||||||
|
npm install
|
||||||
|
npm run compile
|
||||||
|
```
|
||||||
|
|
||||||
|
然后二选一:
|
||||||
|
|
||||||
|
- **开发模式**:在 VSCode 里打开本目录,按 `F5`(Extension Development Host),把插件工程目录作为工作区打开;
|
||||||
|
- **安装到本机**:`npx @vscode/vsce package` 生成 `.vsix`,再 `code --install-extension hmapdev-vscode-0.1.0.vsix`。
|
||||||
|
|
||||||
|
前提:`hmapdev` 在 `PATH` 上(或设置 `hmapdev.path`)。
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
| 设置 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `hmapdev.path` | `hmapdev` | 工具链可执行文件路径 |
|
||||||
|
| `hmapdev.kernelDataDir` | 空 | 内核数据目录(`homed -data` 的那个);填了才能跟随内核日志 |
|
||||||
|
| `hmapdev.diagnoseSdk` | `true` | 是否校验声明的 SDK 是否已安装(需要能执行 hmapdev) |
|
||||||
|
|
||||||
|
## 用法(典型开发回路)
|
||||||
|
|
||||||
|
1. 打开插件工程(含 `plg.json`)→ 状态栏出现 `插件 · SDK <版本> · hmapdev <版本>`;
|
||||||
|
2. 若 `sdk` 报错(未声明 / 区间写法 / 未安装)→ 按提示执行 `hmapdev: 安装 SDK 版本…`,再 `hmapdev: 刷新状态`;
|
||||||
|
3. `hmapdev: 构建插件`(或 `构建(全部目标平台)`)→ 编译错误直接进 Problems;
|
||||||
|
4. 快速验证行为:`hmapdev: 运行插件(解释执行)`;
|
||||||
|
5. 与内核联调:设置 `hmapdev.kernelDataDir` → `hmapdev: 跟随内核日志`,只看本插件的行;
|
||||||
|
6. 改代码 → 重复 3/5。装进内核时记得**与内核同批替换**(协议绑定的产物不支持滚动升级)。
|
||||||
|
|
||||||
|
## 诚实的边界
|
||||||
|
|
||||||
|
- **这不是源码级调试器**:没有断点/单步。插件的 Go 代码要么编译成产物在内核里跑、要么用
|
||||||
|
`hmapdev debug`(yaegi 解释执行)跑,两条路都不提供 DAP 调试会话。本扩展做的是
|
||||||
|
「构建 + 运行 + 看内核日志 + 清单校验」,这也是插件问题实际能被定位的方式。
|
||||||
|
- **Windows 目标**:不支持(协议 2 的统一共享内存区未移植到 Windows,内核侧改走 WSL2),
|
||||||
|
扩展只给提示,不假装能构建。
|
||||||
|
- **`sdk` 字段的语义**:它声明的是**本插件针对的 SDK 版本**(= 接口线),不是内核版本。
|
||||||
|
SDK 版本跟随内核中版本、patch 位恒为 `.0`。
|
||||||
59
tools/vscode-hmapdev/package-lock.json
generated
Normal file
59
tools/vscode-hmapdev/package-lock.json
generated
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "hmapdev-vscode",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "hmapdev-vscode",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"license": "AGPL-3.0-only",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/vscode": "^1.85.0",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"vscode": "^1.85.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "22.20.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@types/node/-/node-22.20.2.tgz",
|
||||||
|
"integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/vscode": {
|
||||||
|
"version": "1.137.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@types/vscode/-/vscode-1.137.0.tgz",
|
||||||
|
"integrity": "sha512-0dc/BBWxkyUsJzXIZ7PkKSalThmS4xiBT+8YEDiWdCefRKHGVV5ZNkM5NB5ULYamallYJujIfncNoXWFlyzL8A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
174
tools/vscode-hmapdev/package.json
Normal file
174
tools/vscode-hmapdev/package.json
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
{
|
||||||
|
"name": "hmapdev-vscode",
|
||||||
|
"displayName": "HomeAgent Plugin Dev (hmapdev)",
|
||||||
|
"description": "调试与构建 HomeAgent 插件工程:plg.json 校验、SDK 版本解析、hmapdev 构建/运行、内核日志跟随。",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"publisher": "JianFeeeee",
|
||||||
|
"license": "AGPL-3.0-only",
|
||||||
|
"private": true,
|
||||||
|
"engines": {
|
||||||
|
"vscode": "^1.85.0"
|
||||||
|
},
|
||||||
|
"categories": [
|
||||||
|
"Programming Languages",
|
||||||
|
"Debuggers",
|
||||||
|
"Other"
|
||||||
|
],
|
||||||
|
"main": "./out/extension.js",
|
||||||
|
"activationEvents": [
|
||||||
|
"workspaceContains:plg.json",
|
||||||
|
"workspaceContains:**/plg.json"
|
||||||
|
],
|
||||||
|
"contributes": {
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"command": "hmapdev.build",
|
||||||
|
"title": "hmapdev: 构建插件"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "hmapdev.buildAll",
|
||||||
|
"title": "hmapdev: 构建插件(全部目标平台)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "hmapdev.clean",
|
||||||
|
"title": "hmapdev: 清理产物"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "hmapdev.run",
|
||||||
|
"title": "hmapdev: 运行插件(解释执行,快速迭代)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "hmapdev.showVersion",
|
||||||
|
"title": "hmapdev: 查看工具链版本"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "hmapdev.listSdk",
|
||||||
|
"title": "hmapdev: 列出 SDK 版本"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "hmapdev.installSdk",
|
||||||
|
"title": "hmapdev: 安装 SDK 版本…"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "hmapdev.useSdk",
|
||||||
|
"title": "hmapdev: 切换当前 SDK 版本…"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "hmapdev.tailKernelLog",
|
||||||
|
"title": "hmapdev: 跟随内核日志(按插件过滤)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "hmapdev.stopTailKernelLog",
|
||||||
|
"title": "hmapdev: 停止跟随内核日志"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "hmapdev.openPlgJson",
|
||||||
|
"title": "hmapdev: 打开 plg.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "hmapdev.refresh",
|
||||||
|
"title": "hmapdev: 刷新状态(重新探测工具链与 SDK)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration": {
|
||||||
|
"title": "HomeAgent Plugin Dev",
|
||||||
|
"properties": {
|
||||||
|
"hmapdev.path": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "hmapdev",
|
||||||
|
"description": "hmapdev 可执行文件路径(默认从 PATH 找)。"
|
||||||
|
},
|
||||||
|
"hmapdev.kernelDataDir": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "",
|
||||||
|
"description": "内核数据目录(homed -data 的那个目录)。填了才能跟随内核日志调试;留空则「跟随内核日志」会先询问。"
|
||||||
|
},
|
||||||
|
"hmapdev.diagnoseSdk": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "校验 plg.json 里声明的 SDK 版本是否已安装在本地 SDK 存储(需要能执行 hmapdev)。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"taskDefinitions": [
|
||||||
|
{
|
||||||
|
"type": "hmapdev",
|
||||||
|
"required": [
|
||||||
|
"action"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"build",
|
||||||
|
"buildAll",
|
||||||
|
"clean",
|
||||||
|
"run"
|
||||||
|
],
|
||||||
|
"description": "要执行的 hmapdev 动作。"
|
||||||
|
},
|
||||||
|
"cwd": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "插件工程目录(默认取 plg.json 所在目录)。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"problemMatchers": [
|
||||||
|
{
|
||||||
|
"name": "hmapdev-go",
|
||||||
|
"owner": "go",
|
||||||
|
"source": "hmapdev",
|
||||||
|
"fileLocation": [
|
||||||
|
"relative",
|
||||||
|
"${workspaceFolder}"
|
||||||
|
],
|
||||||
|
"pattern": [
|
||||||
|
{
|
||||||
|
"regexp": "^(.+\\.go):(\\d+):(\\d+):\\s+(.+)$",
|
||||||
|
"file": 1,
|
||||||
|
"line": 2,
|
||||||
|
"column": 3,
|
||||||
|
"message": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"regexp": "^(.+\\.go):(\\d+):\\s+(.+)$",
|
||||||
|
"file": 1,
|
||||||
|
"line": 2,
|
||||||
|
"message": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"languages": [
|
||||||
|
{
|
||||||
|
"id": "json",
|
||||||
|
"filenames": [
|
||||||
|
"plg.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"jsonValidation": [
|
||||||
|
{
|
||||||
|
"fileMatch": "plg.json",
|
||||||
|
"url": "./schema/plg.schema.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"snippets": [
|
||||||
|
{
|
||||||
|
"language": "json",
|
||||||
|
"path": "./snippets/plg.json.code-snippets"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"compile": "tsc -p ./",
|
||||||
|
"watch": "tsc -watch -p ./",
|
||||||
|
"test": "tsc -p ./ && node --test out/test/*.test.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/vscode": "^1.85.0",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
41
tools/vscode-hmapdev/schema/plg.schema.json
Normal file
41
tools/vscode-hmapdev/schema/plg.schema.json
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"title": "HomeAgent 插件清单(plg.json)",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["name", "version", "entry"],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "插件名(与目录名一致最省事)"
|
||||||
|
},
|
||||||
|
"name_zh": { "type": "string", "description": "中文显示名" },
|
||||||
|
"name_en": { "type": "string", "description": "英文显示名" },
|
||||||
|
"version": { "type": "string", "description": "插件自身版本号(如 0.1.0),与内核/SDK 版本无关" },
|
||||||
|
"description": { "type": "string" },
|
||||||
|
"author": { "type": "string" },
|
||||||
|
"entry": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "入口产物文件名(子进程模式通常是 plugin.bin;Lua 是 main.lua)"
|
||||||
|
},
|
||||||
|
"sdk": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^v?\\d+\\.\\d+\\.\\d+$",
|
||||||
|
"description": "本插件针对的 SDK 版本,必须是完整版本号(如 1.2.0)。SDK 版本跟随内核中版本、patch 位恒为 .0,一条内核线只有一个 SDK 版本;工具链按此在本地 SDK 存储里选择版本。"
|
||||||
|
},
|
||||||
|
"tags": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"targets": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "目标平台,逗号分隔,如 linux/amd64,darwin/arm64(windows 目标暂不支持插件产物)"
|
||||||
|
},
|
||||||
|
"outdir": { "type": "string", "description": "产物目录(默认 dist)" },
|
||||||
|
"bundle": { "type": "boolean", "description": "是否打包成 .hmap(默认 true)" },
|
||||||
|
"sdk_path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "直接指定 SDK 源码目录(本机改 SDK 联调时用);设置后优先于 sdk 字段"
|
||||||
|
},
|
||||||
|
"go_version": { "type": "string" },
|
||||||
|
"replaces": { "type": "object", "additionalProperties": { "type": "string" } },
|
||||||
|
"source_dirs": { "type": "array", "items": { "type": "string" } }
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
26
tools/vscode-hmapdev/snippets/plg.json.code-snippets
Normal file
26
tools/vscode-hmapdev/snippets/plg.json.code-snippets
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"plg.json 骨架": {
|
||||||
|
"prefix": "plg",
|
||||||
|
"body": [
|
||||||
|
"{",
|
||||||
|
" \"name\": \"${1:MyPlugin}\",",
|
||||||
|
" \"name_zh\": \"${2:中文名}\",",
|
||||||
|
" \"name_en\": \"${1:MyPlugin}\",",
|
||||||
|
" \"version\": \"0.1.0\",",
|
||||||
|
" \"description\": \"${3:插件说明}\",",
|
||||||
|
" \"author\": \"${4:HomeAgent}\",",
|
||||||
|
" \"entry\": \"plugin.bin\",",
|
||||||
|
" \"sdk\": \"${5:1.2.0}\",",
|
||||||
|
" \"tags\": [\"${1:MyPlugin}\"],",
|
||||||
|
" \"targets\": \"linux/amd64,darwin/arm64\"",
|
||||||
|
"}",
|
||||||
|
"$0"
|
||||||
|
],
|
||||||
|
"description": "plg.json 必需字段骨架(sdk 必须是完整版本号)"
|
||||||
|
},
|
||||||
|
"sdk 字段": {
|
||||||
|
"prefix": "sdk",
|
||||||
|
"body": ["\"sdk\": \"${1:1.2.0}\","],
|
||||||
|
"description": "声明本插件针对的 SDK 版本(完整版本号;patch 位恒为 .0)"
|
||||||
|
}
|
||||||
|
}
|
||||||
174
tools/vscode-hmapdev/src/core.ts
Normal file
174
tools/vscode-hmapdev/src/core.ts
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* 纯逻辑层:不 import vscode,便于用 node --test 直接单测。
|
||||||
|
*
|
||||||
|
* 这里的规矩必须与工具链一致(tools/hmapdev/sdk_resolve.go):
|
||||||
|
* - SDK 版本跟随内核中版本、**patch 位恒为 .0** → 一条内核线只有一个 SDK 版本;
|
||||||
|
* - 因此 plg.json 的 `sdk` 必须是**完整版本号**(x.y.z),区间写法("1.2")要报错,
|
||||||
|
* 否则项目会以为「同一条线里还能挑不同 SDK」。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** plg.json 的字段(未知字段保留,不做拒绝)。 */
|
||||||
|
export interface PlgConfig {
|
||||||
|
name?: string;
|
||||||
|
name_zh?: string;
|
||||||
|
name_en?: string;
|
||||||
|
version?: string;
|
||||||
|
description?: string;
|
||||||
|
author?: string;
|
||||||
|
entry?: string;
|
||||||
|
sdk?: string;
|
||||||
|
tags?: string[];
|
||||||
|
targets?: string;
|
||||||
|
sdk_path?: string;
|
||||||
|
outdir?: string;
|
||||||
|
bundle?: boolean;
|
||||||
|
replaces?: Record<string, string>;
|
||||||
|
source_dirs?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 诊断级别(与 vscode.DiagnosticSeverity 数值对齐,避免耦合)。 */
|
||||||
|
export enum Severity {
|
||||||
|
Error = 0,
|
||||||
|
Warning = 1,
|
||||||
|
Information = 2,
|
||||||
|
Hint = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlgDiagnostic {
|
||||||
|
severity: Severity;
|
||||||
|
message: string;
|
||||||
|
/** plg.json 里的字段名(用于在 JSON 文档里定位)。 */
|
||||||
|
field?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 完整版本号(x.y.z,允许 v 前缀)。 */
|
||||||
|
export function isFullVersion(v: string): boolean {
|
||||||
|
return /^v?\d+\.\d+\.\d+$/.test((v ?? "").trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 中版本(x.y)。 */
|
||||||
|
export function isMinorVersion(v: string): boolean {
|
||||||
|
return /^v?\d+\.\d+$/.test((v ?? "").trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeVersion(v: string): string {
|
||||||
|
return (v ?? "").trim().replace(/^v/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 plg.json。
|
||||||
|
*
|
||||||
|
* `installedSdks` 为本地 SDK 存储里已安装的版本(不带 v 前缀);传 undefined 表示
|
||||||
|
* 没探测(例如工具链不可用),此时只校验格式、不报「未安装」。
|
||||||
|
*/
|
||||||
|
export function validatePlg(cfg: PlgConfig, installedSdks?: string[]): PlgDiagnostic[] {
|
||||||
|
const out: PlgDiagnostic[] = [];
|
||||||
|
const req = (field: keyof PlgConfig, hint: string) => {
|
||||||
|
const v = cfg[field];
|
||||||
|
if (v === undefined || v === null || String(v).trim() === "") {
|
||||||
|
out.push({ severity: Severity.Error, message: `${field} 不能为空(${hint})`, field: field as string });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
req("name", "插件名,与目录名一致最省事");
|
||||||
|
req("entry", "入口产物,子进程模式通常是 plugin.bin");
|
||||||
|
req("version", "插件自身版本号,如 0.1.0");
|
||||||
|
|
||||||
|
// SDK 声明:这是「工具链自动选 SDK 版本」的依据,缺了就只能退回 current
|
||||||
|
if (cfg.sdk === undefined || cfg.sdk === null || String(cfg.sdk).trim() === "") {
|
||||||
|
out.push({
|
||||||
|
severity: Severity.Warning,
|
||||||
|
message: "缺少 sdk 字段:工具链无法据此选择 SDK 版本,会退回存储里的 current(换机器/换人后容易编出与预期不符的产物)",
|
||||||
|
field: "sdk",
|
||||||
|
});
|
||||||
|
} else if (isMinorVersion(cfg.sdk)) {
|
||||||
|
out.push({
|
||||||
|
severity: Severity.Error,
|
||||||
|
message:
|
||||||
|
`sdk 必须是完整版本号(如 "1.2.0"):${cfg.sdk} 这种区间写法会让人以为同一条内核线里还能挑不同 SDK。` +
|
||||||
|
`SDK 版本跟随内核中版本、patch 位恒为 .0,一条内核线只有一个 SDK 版本。`,
|
||||||
|
field: "sdk",
|
||||||
|
});
|
||||||
|
} else if (!isFullVersion(cfg.sdk)) {
|
||||||
|
out.push({ severity: Severity.Error, message: `sdk 不是合法版本号(写法:"1.2.0")`, field: "sdk" });
|
||||||
|
} else if (installedSdks && !installedSdks.includes(normalizeVersion(cfg.sdk))) {
|
||||||
|
const have = installedSdks.length ? installedSdks.join(", ") : "(存储里还没有任何 SDK)";
|
||||||
|
out.push({
|
||||||
|
severity: Severity.Error,
|
||||||
|
message: `声明的 SDK ${normalizeVersion(cfg.sdk)} 未安装。已安装:${have}。安装:hmapdev sdk install v${normalizeVersion(cfg.sdk)}`,
|
||||||
|
field: "sdk",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 目标平台:windows 目前不支持(协议 2 的统一共享内存区未移植)
|
||||||
|
const targets = (cfg.targets ?? "").toLowerCase();
|
||||||
|
if (targets.includes("windows")) {
|
||||||
|
out.push({
|
||||||
|
severity: Severity.Information,
|
||||||
|
message: "windows 目标暂不支持插件产物:协议 2 的统一共享内存区未移植 Windows(内核改走 WSL2)。构建会在该目标上明确报错。",
|
||||||
|
field: "targets",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!cfg.targets) {
|
||||||
|
out.push({ severity: Severity.Information, message: "未声明 targets,构建时按默认目标处理", field: "targets" });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 `hmapdev sdk list` 的输出,返回已安装版本(去 v 前缀、升序)。
|
||||||
|
*
|
||||||
|
* 输出形如:
|
||||||
|
* Installed SDK versions:
|
||||||
|
* * v1.2.0
|
||||||
|
* v0.8.0
|
||||||
|
* 每行可能带 `*` 标记(当前版本)或前导空格。
|
||||||
|
*/
|
||||||
|
export function parseSdkList(text: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const raw of (text ?? "").split(/\r?\n/)) {
|
||||||
|
const line = raw.trim().replace(/^\*\s*/, "").trim();
|
||||||
|
const m = /^v?(\d+\.\d+\.\d+)$/.exec(line);
|
||||||
|
if (m) {
|
||||||
|
out.push(m[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.sort(compareVersions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 `hmapdev version` 的自述(首行形如 `hmapdev 1.2.0`)。
|
||||||
|
*
|
||||||
|
* 只认**以数字开头**的版本 token:否则 `hmapdev 未找到` / `hmapdev error`
|
||||||
|
* 这类输出会被当成版本号,把「工具链不在」误报成「工具链 1.x」
|
||||||
|
* (状态栏与「是否已装 SDK」的判断都基于它,假版本会让诊断全面失真)。
|
||||||
|
*/
|
||||||
|
export function parseToolchainVersion(text: string): string {
|
||||||
|
for (const raw of (text ?? "").split(/\r?\n/)) {
|
||||||
|
const m = /^hmapdev\s+v?(\d+(?:\.\d+)*(?:[-+.][0-9A-Za-z.-]+)?)\s*$/.exec(raw.trim());
|
||||||
|
if (m) {
|
||||||
|
return m[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 数值比较 x.y.z(字典序会把 1.2.9 排在 1.2.10 之后)。 */
|
||||||
|
export function compareVersions(a: string, b: string): number {
|
||||||
|
const pa = normalizeVersion(a).split(".").map((n) => parseInt(n, 10) || 0);
|
||||||
|
const pb = normalizeVersion(b).split(".").map((n) => parseInt(n, 10) || 0);
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
||||||
|
if (d !== 0) {
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 状态栏文本:插件 + 声明 SDK + 工具链版本(缺项用 "?")。 */
|
||||||
|
export function statusBarText(cfg: PlgConfig | undefined, toolchainVersion: string): string {
|
||||||
|
const name = cfg?.name?.trim() || "(未识别插件)";
|
||||||
|
const sdk = cfg?.sdk ? normalizeVersion(cfg.sdk) : "未声明";
|
||||||
|
const tc = toolchainVersion ? `hmapdev ${toolchainVersion}` : "hmapdev 未找到";
|
||||||
|
return `${name} · SDK ${sdk} · ${tc}`;
|
||||||
|
}
|
||||||
272
tools/vscode-hmapdev/src/extension.ts
Normal file
272
tools/vscode-hmapdev/src/extension.ts
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
import * as cp from "child_process";
|
||||||
|
import * as path from "path";
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
|
||||||
|
import { PlgConfig, Severity, statusBarText, validatePlg } from "./core";
|
||||||
|
import { Toolchain } from "./toolchain";
|
||||||
|
|
||||||
|
let out: vscode.OutputChannel;
|
||||||
|
let tc: Toolchain;
|
||||||
|
let status: vscode.StatusBarItem;
|
||||||
|
let diagnostics: vscode.DiagnosticCollection;
|
||||||
|
let tailChild: cp.ChildProcess | undefined;
|
||||||
|
|
||||||
|
/** 找到工作区里的 plg.json(多个时取第一个并提示)。 */
|
||||||
|
async function findPlg(): Promise<vscode.Uri | undefined> {
|
||||||
|
const found = await vscode.workspace.findFiles("**/plg.json", "**/{node_modules,out,dist,build}/**", 5);
|
||||||
|
if (found.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return found[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPlg(uri: vscode.Uri): Promise<PlgConfig | undefined> {
|
||||||
|
try {
|
||||||
|
const txt = Buffer.from(await vscode.workspace.fs.readFile(uri)).toString("utf8");
|
||||||
|
return JSON.parse(txt) as PlgConfig;
|
||||||
|
} catch (e) {
|
||||||
|
out.appendLine(`error: 解析 ${uri.fsPath} 失败:${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityToVscode(s: Severity): vscode.DiagnosticSeverity {
|
||||||
|
switch (s) {
|
||||||
|
case Severity.Error:
|
||||||
|
return vscode.DiagnosticSeverity.Error;
|
||||||
|
case Severity.Warning:
|
||||||
|
return vscode.DiagnosticSeverity.Warning;
|
||||||
|
case Severity.Information:
|
||||||
|
return vscode.DiagnosticSeverity.Information;
|
||||||
|
default:
|
||||||
|
return vscode.DiagnosticSeverity.Hint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在 JSON 文档里定位字段(找不到就标整个文件,至少让人看见)。 */
|
||||||
|
function rangeForField(doc: vscode.TextDocument, field?: string): vscode.Range {
|
||||||
|
if (field) {
|
||||||
|
const idx = doc.getText().indexOf(`"${field}"`);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const start = doc.positionAt(idx);
|
||||||
|
const end = doc.positionAt(idx + field.length + 2);
|
||||||
|
return new vscode.Range(start, end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
const uri = await findPlg();
|
||||||
|
diagnostics.clear();
|
||||||
|
if (!uri) {
|
||||||
|
status.text = statusBarText(undefined, await tc.version());
|
||||||
|
status.tooltip = "工作区里没有找到 plg.json(本扩展只在插件工程里工作)";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cfg = await readPlg(uri);
|
||||||
|
if (!cfg) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tcVersion = await tc.version();
|
||||||
|
const diagSetting = vscode.workspace.getConfiguration("hmapdev").get<boolean>("diagnoseSdk", true);
|
||||||
|
const installed = diagSetting ? await tc.sdkList() : undefined;
|
||||||
|
|
||||||
|
const doc = await vscode.workspace.openTextDocument(uri);
|
||||||
|
const items = validatePlg(cfg, installed).map((d) => {
|
||||||
|
const vd = new vscode.Diagnostic(rangeForField(doc, d.field), d.message, severityToVscode(d.severity));
|
||||||
|
vd.source = "hmapdev";
|
||||||
|
return vd;
|
||||||
|
});
|
||||||
|
diagnostics.set(uri, items);
|
||||||
|
|
||||||
|
const errors = items.filter((d) => d.severity === vscode.DiagnosticSeverity.Error).length;
|
||||||
|
status.text = `$(tools) ${statusBarText(cfg, tcVersion)}`;
|
||||||
|
status.backgroundColor = tc.isMissing()
|
||||||
|
? new vscode.ThemeColor("statusBarItem.errorBackground")
|
||||||
|
: errors > 0
|
||||||
|
? new vscode.ThemeColor("statusBarItem.warningBackground")
|
||||||
|
: undefined;
|
||||||
|
const installedText = installed ? installed.join(", ") || "(无)" : "(未探测)";
|
||||||
|
status.tooltip = [
|
||||||
|
`插件:${cfg.name ?? "?"}`,
|
||||||
|
`声明 SDK:${cfg.sdk ?? "未声明"}`,
|
||||||
|
`已安装 SDK:${installedText}`,
|
||||||
|
`工具链:${tcVersion ? `hmapdev ${tcVersion}` : "未找到(检查 hmapdev.path / PATH)"}`,
|
||||||
|
`plg.json:${uri.fsPath}`,
|
||||||
|
].join("\n");
|
||||||
|
status.command = "hmapdev.openPlgJson";
|
||||||
|
status.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pluginDir(): Promise<string | undefined> {
|
||||||
|
const uri = await findPlg();
|
||||||
|
return uri ? path.dirname(uri.fsPath) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withDir(fn: (dir: string) => unknown | Promise<unknown>): Promise<void> {
|
||||||
|
const dir = await pluginDir();
|
||||||
|
if (!dir) {
|
||||||
|
void vscode.window.showWarningMessage("当前工作区没有 plg.json,无法定位插件工程。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fn(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activate(context: vscode.ExtensionContext): void {
|
||||||
|
out = vscode.window.createOutputChannel("hmapdev");
|
||||||
|
tc = new Toolchain(out);
|
||||||
|
diagnostics = vscode.languages.createDiagnosticCollection("hmapdev");
|
||||||
|
status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100);
|
||||||
|
|
||||||
|
context.subscriptions.push(out, diagnostics, status);
|
||||||
|
|
||||||
|
const reg = (id: string, fn: () => unknown) =>
|
||||||
|
context.subscriptions.push(vscode.commands.registerCommand(id, async () => {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
out.appendLine(`error: ${msg}`);
|
||||||
|
void vscode.window.showErrorMessage(`hmapdev: ${msg}`);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
reg("hmapdev.build", () => withDir((d) => tc.runInTerminal(["build"], d, "hmapdev build")));
|
||||||
|
reg("hmapdev.buildAll", () => withDir((d) => tc.runInTerminal(["build", "--target", "all"], d, "hmapdev build all")));
|
||||||
|
reg("hmapdev.clean", () => withDir((d) => tc.runInTerminal(["clean"], d, "hmapdev clean")));
|
||||||
|
reg("hmapdev.run", () => withDir((d) => tc.runInTerminal(["debug", d], d, "hmapdev debug")));
|
||||||
|
|
||||||
|
reg("hmapdev.showVersion", async () => {
|
||||||
|
out.show(true);
|
||||||
|
const v = await tc.version(true);
|
||||||
|
if (!v) {
|
||||||
|
void vscode.window.showErrorMessage("找不到 hmapdev:请把它放到 PATH,或设置 hmapdev.path。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await tc.runAndLog(["version"], process.cwd());
|
||||||
|
});
|
||||||
|
|
||||||
|
reg("hmapdev.listSdk", async () => {
|
||||||
|
out.show(true);
|
||||||
|
await tc.runAndLog(["sdk", "list"], process.cwd());
|
||||||
|
await refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
reg("hmapdev.installSdk", async () => {
|
||||||
|
const v = await vscode.window.showInputBox({
|
||||||
|
title: "安装 SDK 版本",
|
||||||
|
prompt: '输入完整版本号(如 1.2.0)或 latest。注意:SDK 版本跟随内核中版本,patch 位恒为 .0。',
|
||||||
|
placeHolder: "1.2.0",
|
||||||
|
});
|
||||||
|
if (!v) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tc.runInTerminal(["sdk", "install", `v${v.replace(/^v/, "")}`], process.cwd(), "hmapdev sdk install");
|
||||||
|
void vscode.window.showInformationMessage(`安装完成后执行「hmapdev: 刷新状态」以重新校验。`);
|
||||||
|
});
|
||||||
|
|
||||||
|
reg("hmapdev.useSdk", async () => {
|
||||||
|
const list = await tc.sdkList(true);
|
||||||
|
if (!list || list.length === 0) {
|
||||||
|
void vscode.window.showWarningMessage("没有探测到已安装的 SDK 版本(先跑「hmapdev: 列出 SDK 版本」看看)。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pick = await vscode.window.showQuickPick(list, { title: "切换当前 SDK 版本(存储里的 current)" });
|
||||||
|
if (!pick) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tc.runInTerminal(["sdk", "use", `v${pick}`], process.cwd(), "hmapdev sdk use");
|
||||||
|
setTimeout(() => void refresh(), 1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
reg("hmapdev.tailKernelLog", async () => {
|
||||||
|
const cfgDir = vscode.workspace.getConfiguration("hmapdev");
|
||||||
|
let dataDir = cfgDir.get<string>("kernelDataDir", "");
|
||||||
|
if (!dataDir) {
|
||||||
|
const answer = await vscode.window.showInputBox({
|
||||||
|
title: "内核数据目录",
|
||||||
|
prompt: "homed -data 指向的目录(用于跟随内核日志)。填一次会记住到设置里。",
|
||||||
|
placeHolder: "/home/newqqagent",
|
||||||
|
});
|
||||||
|
if (!answer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dataDir = answer;
|
||||||
|
await cfgDir.update("kernelDataDir", dataDir, vscode.ConfigurationTarget.Workspace);
|
||||||
|
}
|
||||||
|
const uri = await findPlg();
|
||||||
|
const filter = uri ? (await readPlg(uri))?.name ?? "" : "";
|
||||||
|
tailChild?.kill();
|
||||||
|
tailChild = tc.tailKernelLog(dataDir, filter);
|
||||||
|
out.show(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
reg("hmapdev.stopTailKernelLog", () => {
|
||||||
|
if (tailChild) {
|
||||||
|
tailChild.kill();
|
||||||
|
tailChild = undefined;
|
||||||
|
out.appendLine("已停止跟随内核日志");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
reg("hmapdev.openPlgJson", async () => {
|
||||||
|
const uri = await findPlg();
|
||||||
|
if (uri) {
|
||||||
|
await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uri));
|
||||||
|
} else {
|
||||||
|
void vscode.window.showWarningMessage("工作区里没有 plg.json。");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
reg("hmapdev.refresh", async () => {
|
||||||
|
await tc.sdkList(true);
|
||||||
|
await tc.version(true);
|
||||||
|
await refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 任务提供者:把 hmapdev 动作接进 VSCode 的任务体系(可绑定快捷键 / 串联依赖 / 复用问题匹配器)
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.tasks.registerTaskProvider("hmapdev", {
|
||||||
|
provideTasks: async () => {
|
||||||
|
const dir = await pluginDir();
|
||||||
|
if (!dir) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const mk = (action: string, label: string, args: string[]) => {
|
||||||
|
const def: vscode.TaskDefinition = { type: "hmapdev", action };
|
||||||
|
const exec = new vscode.ProcessExecution(
|
||||||
|
vscode.workspace.getConfiguration("hmapdev").get<string>("path", "hmapdev") || "hmapdev",
|
||||||
|
args,
|
||||||
|
{ cwd: dir }
|
||||||
|
);
|
||||||
|
return new vscode.Task(def, vscode.TaskScope.Workspace, label, "hmapdev", exec, ["$hmapdev-go"]);
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
mk("build", "hmapdev: build", ["build"]),
|
||||||
|
mk("buildAll", "hmapdev: build (all targets)", ["build", "--target", "all"]),
|
||||||
|
mk("clean", "hmapdev: clean", ["clean"]),
|
||||||
|
mk("run", "hmapdev: run (interpreted)", ["debug", dir]),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
resolveTask: (task) => task,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// plg.json 变化 → 重算诊断(含保存与外部修改)
|
||||||
|
const watcher = vscode.workspace.createFileSystemWatcher("**/plg.json");
|
||||||
|
context.subscriptions.push(
|
||||||
|
watcher,
|
||||||
|
watcher.onDidChange(() => void refresh()),
|
||||||
|
watcher.onDidCreate(() => void refresh()),
|
||||||
|
watcher.onDidDelete(() => void refresh())
|
||||||
|
);
|
||||||
|
|
||||||
|
void refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deactivate(): void {
|
||||||
|
tailChild?.kill();
|
||||||
|
tailChild = undefined;
|
||||||
|
}
|
||||||
106
tools/vscode-hmapdev/src/test/core.test.ts
Normal file
106
tools/vscode-hmapdev/src/test/core.test.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import * as assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
PlgConfig,
|
||||||
|
Severity,
|
||||||
|
compareVersions,
|
||||||
|
isFullVersion,
|
||||||
|
isMinorVersion,
|
||||||
|
normalizeVersion,
|
||||||
|
parseSdkList,
|
||||||
|
parseToolchainVersion,
|
||||||
|
statusBarText,
|
||||||
|
validatePlg,
|
||||||
|
} from "../core";
|
||||||
|
|
||||||
|
const good: PlgConfig = { name: "memo", version: "0.1.0", entry: "plugin.bin", sdk: "1.2.0" };
|
||||||
|
|
||||||
|
test("isFullVersion / isMinorVersion 区分完整版本与区间写法", () => {
|
||||||
|
assert.equal(isFullVersion("1.2.0"), true);
|
||||||
|
assert.equal(isFullVersion("v1.2.0"), true);
|
||||||
|
assert.equal(isFullVersion("1.2"), false);
|
||||||
|
assert.equal(isFullVersion("1.2.3.4"), false);
|
||||||
|
assert.equal(isMinorVersion("1.2"), true);
|
||||||
|
assert.equal(isMinorVersion("1.2.0"), false);
|
||||||
|
assert.equal(normalizeVersion("v1.2.0"), "1.2.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("合法的 plg.json 不产生错误", () => {
|
||||||
|
const d = validatePlg(good, ["1.2.0"]);
|
||||||
|
assert.equal(d.filter((x) => x.severity === Severity.Error).length, 0, JSON.stringify(d));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("缺必需字段要报错并指出字段", () => {
|
||||||
|
const d = validatePlg({ sdk: "1.2.0" }, ["1.2.0"]);
|
||||||
|
const fields = d.filter((x) => x.severity === Severity.Error).map((x) => x.field).sort();
|
||||||
|
assert.deepEqual(fields, ["entry", "name", "version"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("区间写法 1.2 必须被拒,并说明 patch 位恒为 .0", () => {
|
||||||
|
const d = validatePlg({ ...good, sdk: "1.2" }, ["1.2.0"]);
|
||||||
|
const err = d.find((x) => x.field === "sdk" && x.severity === Severity.Error);
|
||||||
|
assert.ok(err, "区间写法应报错");
|
||||||
|
assert.match(err!.message, /完整版本号/);
|
||||||
|
assert.match(err!.message, /patch 位恒为 \.0/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("缺 sdk 字段只警告(向后兼容存量项目)", () => {
|
||||||
|
const d = validatePlg({ name: "memo", version: "0.1.0", entry: "plugin.bin" }, ["1.2.0"]);
|
||||||
|
const sdk = d.find((x) => x.field === "sdk");
|
||||||
|
assert.ok(sdk);
|
||||||
|
assert.equal(sdk!.severity, Severity.Warning);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("声明的 SDK 未安装要报错并给出安装命令", () => {
|
||||||
|
const d = validatePlg(good, ["0.8.0"]);
|
||||||
|
const err = d.find((x) => x.field === "sdk" && x.severity === Severity.Error);
|
||||||
|
assert.ok(err, "未安装应报错");
|
||||||
|
assert.match(err!.message, /hmapdev sdk install v1\.2\.0/);
|
||||||
|
assert.match(err!.message, /0\.8\.0/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("探测不到已装列表时不误报未安装", () => {
|
||||||
|
const d = validatePlg(good, undefined);
|
||||||
|
assert.equal(d.filter((x) => x.severity === Severity.Error).length, 0, JSON.stringify(d));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("windows 目标给提示(协议 2 未移植)", () => {
|
||||||
|
const d = validatePlg({ ...good, targets: "linux/amd64,windows/amd64" }, ["1.2.0"]);
|
||||||
|
const info = d.find((x) => x.field === "targets");
|
||||||
|
assert.ok(info);
|
||||||
|
assert.match(info!.message, /WSL2/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseSdkList 吃掉 * 标记与空格,并按数值排序", () => {
|
||||||
|
const text = ["Installed SDK versions:", " * v1.2.0", " v0.8.0", " v1.2.10"].join("\n");
|
||||||
|
assert.deepEqual(parseSdkList(text), ["0.8.0", "1.2.0", "1.2.10"]);
|
||||||
|
assert.deepEqual(parseSdkList("No SDK versions installed."), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseToolchainVersion 从 self-report 里取版本", () => {
|
||||||
|
const text = ["hmapdev 1.2.0", " SDK 模块: gitcode.com/JianFeeeee/homeagent-sdk", " 构建用 Go: go1.25.12"].join("\n");
|
||||||
|
assert.equal(parseToolchainVersion(text), "1.2.0");
|
||||||
|
assert.equal(parseToolchainVersion("Usage:\n hmapdev init <name>"), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 反向核对抓到的真缺陷:`hmapdev <非版本>` 形状的输出曾被当成版本号,
|
||||||
|
// 于是「工具链不在」会被显示成「工具链 <垃圾词>」,并让 SDK 诊断跟着失真。
|
||||||
|
test("parseToolchainVersion 不会把非版本 token 当成版本", () => {
|
||||||
|
for (const bad of ["hmapdev 未找到", "hmapdev error", "hmapdev not found", "hmapdev -v", "hmapdev"]) {
|
||||||
|
assert.equal(parseToolchainVersion(bad), "", `不应从 ${JSON.stringify(bad)} 解析出版本`);
|
||||||
|
}
|
||||||
|
assert.equal(parseToolchainVersion("hmapdev 1.3.0-dev"), "1.3.0-dev"); // 开发构建的后缀要带出来
|
||||||
|
assert.equal(parseToolchainVersion("hmapdev v1.2.0"), "1.2.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("compareVersions 是数值比较(1.2.10 > 1.2.9)", () => {
|
||||||
|
assert.ok(compareVersions("1.2.10", "1.2.9") > 0);
|
||||||
|
assert.ok(compareVersions("1.2.0", "1.2.0") === 0);
|
||||||
|
assert.ok(compareVersions("0.8.0", "1.2.0") < 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("状态栏文本包含插件、声明 SDK 与工具链版本", () => {
|
||||||
|
assert.equal(statusBarText(good, "1.2.0"), "memo · SDK 1.2.0 · hmapdev 1.2.0");
|
||||||
|
assert.equal(statusBarText({ ...good, sdk: undefined }, ""), "memo · SDK 未声明 · hmapdev 未找到");
|
||||||
|
});
|
||||||
139
tools/vscode-hmapdev/src/toolchain.ts
Normal file
139
tools/vscode-hmapdev/src/toolchain.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import * as cp from "child_process";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
|
||||||
|
import { parseSdkList, parseToolchainVersion } from "./core";
|
||||||
|
|
||||||
|
/** execFile 的 Promise 版(不引第三方依赖)。 */
|
||||||
|
function execFile(
|
||||||
|
file: string,
|
||||||
|
args: string[],
|
||||||
|
cwd: string,
|
||||||
|
timeoutMs = 120_000
|
||||||
|
): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
cp.execFile(file, args, { cwd, timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
|
||||||
|
const code = err && typeof (err as { code?: number }).code === "number" ? (err as { code: number }).code : err ? 1 : 0;
|
||||||
|
resolve({ code, stdout: stdout ?? "", stderr: stderr ?? "" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具链封装:定位 hmapdev、执行命令、缓存 version / sdk list。
|
||||||
|
*
|
||||||
|
* 为什么要缓存并显式 refresh:SDK 存储会在外部变化(`hmapdev sdk install` 之后),
|
||||||
|
* 而诊断信息依赖它——不刷新就会一直报「未安装」。
|
||||||
|
*/
|
||||||
|
export class Toolchain {
|
||||||
|
private versionCache?: string;
|
||||||
|
private sdkCache?: string[];
|
||||||
|
private missing = false;
|
||||||
|
|
||||||
|
constructor(private readonly out: vscode.OutputChannel) {}
|
||||||
|
|
||||||
|
private get exe(): string {
|
||||||
|
return vscode.workspace.getConfiguration("hmapdev").get<string>("path", "hmapdev") || "hmapdev";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 记录一条消息到输出通道(加上工具链前缀,便于与构建输出区分)。 */
|
||||||
|
log(line: string): void {
|
||||||
|
this.out.appendLine(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
async version(refresh = false): Promise<string> {
|
||||||
|
if (this.versionCache !== undefined && !refresh) {
|
||||||
|
return this.versionCache;
|
||||||
|
}
|
||||||
|
const r = await execFile(this.exe, ["version"], process.cwd(), 20_000);
|
||||||
|
if (r.code !== 0 && !r.stdout) {
|
||||||
|
this.missing = true;
|
||||||
|
this.versionCache = "";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
this.missing = false;
|
||||||
|
this.versionCache = parseToolchainVersion(r.stdout + r.stderr);
|
||||||
|
return this.versionCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sdkList(refresh = false): Promise<string[] | undefined> {
|
||||||
|
if (this.sdkCache !== undefined && !refresh) {
|
||||||
|
return this.sdkCache;
|
||||||
|
}
|
||||||
|
const r = await execFile(this.exe, ["sdk", "list"], process.cwd(), 20_000);
|
||||||
|
if (r.code !== 0 && !r.stdout) {
|
||||||
|
this.sdkCache = undefined; // 探测不到就不做「未安装」判断,避免误报
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
this.sdkCache = parseSdkList(r.stdout + r.stderr);
|
||||||
|
return this.sdkCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
isMissing(): boolean {
|
||||||
|
return this.missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在集成终端里执行(构建/运行这类长命令:要能看进度、能 Ctrl-C)。 */
|
||||||
|
runInTerminal(args: string[], cwd: string, name: string): vscode.Terminal {
|
||||||
|
const term = vscode.window.createTerminal({ name, cwd });
|
||||||
|
term.show(true);
|
||||||
|
const cmd = [this.exe, ...args].map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(" ");
|
||||||
|
this.log(`$ ${cmd}`);
|
||||||
|
term.sendText(cmd, true);
|
||||||
|
return term;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一次性执行并把输出写进输出通道(查询类命令)。 */
|
||||||
|
async runAndLog(args: string[], cwd: string): Promise<number> {
|
||||||
|
this.log(`$ ${this.exe} ${args.join(" ")}`);
|
||||||
|
const r = await execFile(this.exe, args, cwd, 60_000);
|
||||||
|
if (r.stdout) {
|
||||||
|
this.out.append(r.stdout);
|
||||||
|
}
|
||||||
|
if (r.stderr) {
|
||||||
|
this.out.append(r.stderr);
|
||||||
|
}
|
||||||
|
return r.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跟随内核日志:定位 <dataDir>/log 下最新的 homed 日志并按插件名过滤。
|
||||||
|
*
|
||||||
|
* 为什么这是「调试插件」的正路:插件是子进程、跑在内核里,真正的问题几乎都
|
||||||
|
* 表现为内核日志里的几行(握手失败/崩溃重启/工具报错),在 IDE 里跟住它比
|
||||||
|
* 反复手动 tail 高效得多。
|
||||||
|
*/
|
||||||
|
tailKernelLog(dataDir: string, filter: string): cp.ChildProcess | undefined {
|
||||||
|
const logDir = path.join(dataDir, "log");
|
||||||
|
if (!fs.existsSync(logDir)) {
|
||||||
|
this.log(`error: 日志目录不存在:${logDir}(hmapdev.kernelDataDir 是否指对?)`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const newest = fs
|
||||||
|
.readdirSync(logDir)
|
||||||
|
.filter((f) => f.startsWith("homed_") && f.endsWith(".log"))
|
||||||
|
.map((f) => ({ f, m: fs.statSync(path.join(logDir, f)).mtimeMs }))
|
||||||
|
.sort((a, b) => b.m - a.m)[0];
|
||||||
|
if (!newest) {
|
||||||
|
this.log(`error: ${logDir} 下没有 homed_*.log`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const file = path.join(logDir, newest.f);
|
||||||
|
this.log(`跟随 ${file}${filter ? `(过滤 ${filter})` : ""}`);
|
||||||
|
const child = cp.spawn("tail", ["-F", file], { stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
const emit = (buf: Buffer) => {
|
||||||
|
for (const line of buf.toString("utf8").split(/\r?\n/)) {
|
||||||
|
if (!line) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!filter || line.includes(filter)) {
|
||||||
|
this.out.appendLine(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
child.stdout?.on("data", emit);
|
||||||
|
child.stderr?.on("data", emit);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
tools/vscode-hmapdev/tsconfig.json
Normal file
19
tools/vscode-hmapdev/tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"outDir": "out",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["node", "vscode"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "out"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user