Compare commits

...

17 Commits

Author SHA1 Message Date
Alex Newman 85ed7c3d2f Release v3.9.9
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-10-03 18:20:47 -04:00
Alex Newman 4d5b307a74 Release v3.7.2
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-09-22 14:14:51 -04:00
Alex Newman 68566b556c Release v3.7.1
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-09-17 21:20:36 -04:00
Alex Newman b0032c1745 Release v3.7.0
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-09-17 20:19:19 -04:00
Alex Newman 35b7aab174 Release v3.6.10
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-09-16 20:20:56 -04:00
Alex Newman 2601215c91 Release v3.6.9
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-09-14 23:57:39 -04:00
Alex Newman 4ebf0cad6b Release v3.6.8
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-09-14 19:38:27 -04:00
Alex Newman 98d959112c Release v3.6.6
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-09-14 18:48:58 -04:00
Alex Newman d01c2afaa6 Release v3.6.5
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-09-14 14:36:54 -04:00
Alex Newman 8ebcb55b0d Release v3.6.4
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-09-13 22:54:41 -04:00
Alex Newman 97807494fd Release v3.6.3
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-09-11 17:15:50 -04:00
Alex Newman c4eb2e2dc9 Remove Shakespeare's Memory Theatre section 2025-09-10 22:52:24 -04:00
Alex Newman f0c3bf18b0 Release v3.6.2
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-09-10 22:15:14 -04:00
Alex Newman 3eaae66bc4 Release v3.6.1
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-09-10 17:30:06 -04:00
Alex Newman 27d1cd405f Release v3.6.0
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-09-10 15:02:40 -04:00
Alex Newman 267965a065 Remove Shakespeare's Memory Theatre section from README 2025-09-10 12:22:21 -04:00
Alex Newman 181aca0215 Release v3.5.9
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-09-10 01:04:38 -04:00
82 changed files with 17666 additions and 967 deletions
+119
View File
@@ -0,0 +1,119 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [3.7.1] - 2025-09-17
### Added
- SQLite storage backend with session, memory, overview, and diagnostics management
- Mintlify documentation site with searchable interface and comprehensive guides
- Context7 MCP integration for documentation retrieval
### Changed
- Session-start overviews to display chronologically from oldest to newest
### Fixed
- Migration index parsing bug that prevented JSONL records from importing to SQLite
## [3.6.10] - 2025-09-16
### Added
- Claude Code statusline integration for real-time memory status
- MCP memory tools server providing compress, stats, search, and overview commands
- Concept documentation explaining memory compression and context loading
### Fixed
- Corrected integration architecture to use hooks instead of MCP SDK
## [3.6.9] - 2025-09-14
### Added
- Display current date and time at the top of session-start hook output for better temporal context
### Changed
- Enhanced session-start hook formatting with emoji icons and separator lines for improved readability
## [3.6.8] - 2025-09-14
### Fixed
- Fixed publish command failing when no version-related memories exist for changelog generation
## [3.6.6] - 2025-09-14
### Fixed
- Resolved compaction errors when processing large conversation histories by reducing chunk size limits to stay within Claude's context window
## [3.6.5] - 2025-09-14
### Changed
- Session groups now display in chronological order (most recent first)
### Fixed
- Improved CLI path detection for cross-platform compatibility
## [3.6.4] - 2025-09-13
### Changed
- Update save documentation to include allowed-tools and description metadata fields
### Removed
- Remove deprecated markdown to JSONL migration script
## [3.6.3] - 2025-09-11
### Changed
- Updated changelog generation prompts to use date strings in query text for temporal filtering
### Fixed
- Resolved changelog timestamp filtering by using semantic search instead of metadata queries, enabling proper date-based searches
- Corrected install.ts search instructions to remove misleading metadata filtering guidance that caused 'Error finding id' errors
## [3.6.2] - 2025-09-10
### Added
- Visual feedback to changelog command showing current version, next version, and number of overviews being processed
- Generate changelog for specific versions using `--generate` flag with npm publish time boundaries
- Introduce 'Who Wants To Be a Memoryonaire?' trivia game that generates personalized questions from your stored memories
- Add interactive terminal UI with lifelines (50:50, Phone-a-Friend, Audience Poll) and cross-platform audio support
- Implement permanent question caching with --regenerate flag for instant game loading
- Enable hybrid vector search to discover related memory chains during question generation
### Changed
- Changelog regeneration automatically removes old entries from JSONL file when using `--generate` or `--historical` flags
- Switch to direct JSONL file loading for instant memory access without API calls
- Optimize AI generation with faster 'sonnet' model for improved performance
- Reduce memory query limit from 100 to 50 to prevent token overflow
### Fixed
- Changelog command now uses npm publish timestamps exclusively for accurate version time ranges
- Resolved timestamp filtering issues with Chroma database by leveraging semantic search with embedded dates
- Resolve game hanging at startup due to confirmation loop
- Fix memory integration bypass that prevented questions from using actual stored memories
- Consolidate 500+ lines of duplicate code for better maintainability
## [3.6.1] - 2025-09-10
### Changed
- Refactored pre-compact hook to work independently without status line updates
### Removed
- Removed status line integration and ccstatusline configuration support
## [3.5.5] - 2025-09-10
### Changed
- Standardized GitHub release naming to lowercase 'claude-mem vX.X.X' format for consistent branding
+621 -22
View File
@@ -1,31 +1,630 @@
CLAUDE-MEM SOFTWARE LICENSE
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (c) 2024 Alex Newman (@thedotmack). All rights reserved.
Copyright (C) 2025 Alex Newman (@thedotmack). All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software in its compiled/distributed form via npm, to use the software
for any purpose, subject to the following conditions:
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.
1. USE RIGHTS: You may use the claude-mem CLI tool for personal or commercial
purposes without restriction.
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.
2. NO SOURCE CODE RIGHTS: This license does NOT grant access to source code,
modification rights, or redistribution rights. The software is provided
as-is in its compiled form only.
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/>.
3. NO REVERSE ENGINEERING: You may not reverse engineer, decompile, or
disassemble the software.
Preamble
4. NO REDISTRIBUTION: You may not redistribute, repackage, or resell this
software. Users must install it from the official npm registry.
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.
5. NO WARRANTY: THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
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.
6. LIMITATION OF LIABILITY: IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT
OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR
THE USE OR OTHER DEALINGS IN THE SOFTWARE.
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.
For questions about this license, contact: thedotmack@gmail.com
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
+239 -105
View File
@@ -1,114 +1,248 @@
# 🧠 Claude Memory System (claude-mem)
## Remember that one thing? Neither do we… but `claude-mem` does! 😵‍💫
A real-time memory system for Claude Code that captures, compresses, and retrieves conversation context across sessions using semantic search and vector embeddings.
Stop repeating yourself. `claude-mem` remembers what you and Claude Code figure out, so every new chat starts smarter than the last.
## ⚡️ 10Second Setup
```bash
npm install -g claude-mem && claude-mem install
```
Thats it. Restart Claude Code and youre good. No config. No tedious setup or dependencies.
## ✨ What You Get
- Remembers key insights from your chats with Claude Code
- Starts new sessions with the right context
- Works quietly in the background
- One-command install and status check
- **🎭 Shakespeare's Memory Theatre**: Transform operations into theatrical magnificence!
## 🎭 NEW: Shakespeare's Memory Theatre
*"All the world's a stage, And all memory merely players"*
Experience claude-mem operations as a dramatic performance! Choose from five theatrical acts:
```bash
claude-mem theatre # Full theatrical experience
claude-mem theatre --act I # Tragedy of Defensive Validation
claude-mem theatre --act II # Romeo and Chroma_Add
claude-mem compress-theatrical file # Compress with Shakespearean flair
claude-mem status-theatrical # Check status dramatically
```
**Interactive Features:**
- 🎪 Choose dialogue responses
- 🍅 Throw tomatoes at bad code
- 👏 Standing ovation meter
- 📜 Soliloquy generator
- 🎺 Trumpet fanfares
[📖 Full Shakespeare Theatre Documentation](docs/shakespeare-theatre.md)
## 🗑️ Smart Trash™ (Your Panic Button)
Delete something by accident? Its not gone.
- Everything goes to `~/.claude-mem/trash/`
- Restore with a single command: `claude-mem restore`
- Timestamped so you can see when things moved
## 🎯 Why Its Useful
- No more re-explaining your project over and over
- Pick up exactly where you left off
- Find past solutions fast when you face a familiar bug
- Your knowledge compounds the more you use it
## 🧭 Minimal Commands Youll Ever Need
```bash
claude-mem install # Set up/repair integration
claude-mem status # Check everythings working
claude-mem load-context # Peek at what it remembers
claude-mem logs # If youre curious
claude-mem uninstall # Remove hooks
# Extras
claude-mem trash-view # See whats in Smart Trash™
claude-mem restore # Restore deleted items
# 🎭 Shakespeare's Memory Theatre Commands
claude-mem theatre # Experience memory operations dramatically
claude-mem compress-theatrical file.jsonl # Theatrical compression
claude-mem status-theatrical # Dramatic status check
```
## 📁 Where Stuff Lives (super simple)
```
~/.claude-mem/
├── index/ # memory index
├── archives/ # transcripts
├── hooks/ # integration bits
├── trash/ # Smart Trash™
└── logs/ # diagnostics
```
## ✅ Requirements
- Node.js 18+
- Claude Code
## 🆘 If Somethings Weird
```bash
claude-mem status # quick health check
claude-mem install --force # fixes most issues
```
## 📄 License
This software is free to use but is NOT open source. See `LICENSE`.
---
## Ready to remember more and repeat less?
## ⚡️ Quick Start
```bash
npm install -g claude-mem
claude-mem install
```
Your future self will thank you. 🧠✨
Restart Claude Code. Memory capture starts automatically.
## ✨ What It Does
**Real-Time Memory Capture**
- Captures every conversation turn as it happens via streaming hooks
- User prompts stored immediately in ChromaDB with atomic facts
- Tool responses compressed asynchronously via Agent SDK
- Project-based memory isolation with hierarchical metadata
- Automatic context loading at session start and `/clear`
**Semantic Search**
- Vector embeddings for intelligent retrieval via ChromaDB
- Find relevant context from past conversations
- Project-aware memory queries with temporal filtering
- Date-based search using query text (not metadata)
- 15+ MCP tools for memory operations
**Invisible Operation**
- Zero user configuration required
- Memory compression happens in background via SDK
- SDK transcripts auto-deleted from UI history
- Session overviews generated automatically
- Live memory viewer with SSE streaming
**Smart Trash™**
- Safe deletion with easy recovery
- Timestamped trash entries
- One-command restore
- Located at `~/.claude-mem/trash/`
## 🎯 Core Features
- **Streaming Hooks**: Real-time capture with minimal overhead (<50ms)
- **Agent SDK Integration**: Async compression without blocking conversation
- **MCP Server**: 15+ ChromaDB tools for memory operations
- **Project Isolation**: Memories segregated by project context
- **Zero Configuration**: Works out of the box after install
- **Embedded Databases**: ChromaDB and SQLite, no external dependencies
- **Invisible UX**: Memory operations don't pollute conversation UI
- **Live Memory Viewer**: Real-time slideshow of memories via SSE
## 🧭 Commands
```bash
# Setup & Status
claude-mem install # Install/repair hooks and MCP integration
claude-mem status # Check installation and memory stats
claude-mem doctor # Run environment and pipeline diagnostics
claude-mem uninstall # Remove all hooks
# Memory Operations
claude-mem load-context # View current session context
claude-mem logs # View operation logs
claude-mem changelog # Generate CHANGELOG.md from memories
# Storage Operations (Used by hooks/SDK)
claude-mem store-memory # Store a memory to ChromaDB + SQLite
claude-mem store-overview # Store a session overview
# Smart Trash™
claude-mem trash # View trash contents
claude-mem restore # Restore from trash
claude-mem trash-empty # Permanently delete trash
# ChromaDB Tools (15+ MCP tools available)
claude-mem chroma_* # Direct ChromaDB operations
```
## 📁 Storage Structure
```
~/.claude-mem/
├── chroma/ # ChromaDB vector database
├── archives/ # Compressed transcript backups
├── index/ # Legacy JSONL memory indices
├── hooks/ # Hook configuration files
├── trash/ # Smart Trash™ with recovery
├── logs/ # Operation logs
└── claude-mem.db # SQLite metadata database
```
## 🏗️ Architecture
**Storage Layers**
- **ChromaDB**: Vector database for semantic search with embeddings
- **SQLite**: Metadata index (`~/.claude-mem/claude-mem.db`) with sessions, memories, overviews
- **Archives**: Compressed transcript backups in `~/.claude-mem/archives/`
**Hook System** (`hook-templates/`)
- `user-prompt-submit.js`: Captures user prompts immediately, stores in ChromaDB
- `post-tool-use.js`: Spawns Agent SDK for async compression of tool responses
- `stop.js`: Generates session overview, cleans up SDK transcripts from UI
- `session-start.js`: Loads relevant context on startup and `/clear`
- Shared utilities: `hook-helpers.js`, `hook-prompt-renderer.js`, `config-loader.js`, `path-resolver.js`
**CLI Commands** (`src/commands/`)
- Installation, status, and diagnostics
- Memory storage and retrieval
- Changelog generation from memories
- Smart Trash™ management
- 15+ dynamic ChromaDB MCP tool wrappers
**Services** (`src/services/`)
- SQLite stores: Session, Memory, Overview, Diagnostics, TranscriptEvent
- Path discovery for project detection
- Rolling settings and logs
## 🔍 How Memory Search Works
**Semantic Search Best Practices**:
```typescript
// ALWAYS include project name to avoid cross-contamination
mcp__claude-mem__chroma_query_documents({
collection_name: "claude_memories",
query_texts: ["claude-mem authentication bug"],
n_results: 10
})
// Include dates for temporal search (dates in query text, not metadata)
mcp__claude-mem__chroma_query_documents({
collection_name: "claude_memories",
query_texts: ["project-name 2025-10-02 feature implementation"],
n_results: 5
})
// Intent-based queries work better than keyword matching
mcp__claude-mem__chroma_query_documents({
collection_name: "claude_memories",
query_texts: ["implementing oauth flow"],
n_results: 10
})
```
**What Doesn't Work** (Avoid These!)
- ❌ Complex `where` filters with `$and`/`$or` - causes errors
- ❌ Timestamp comparisons (`$gte`, `$lt`) - stored as strings
- ❌ Mixing project filters in where clause - causes "Error finding id"
**Storage Collection**: `claude_memories`
- Metadata: `project`, `session_id`, `date`, `type`, `concepts`, `files`
- Embeddings: Semantic vectors for similarity search
- Documents: Atomic facts + full narrative with hierarchical structure
## ✅ Requirements
- Node.js >= 18.0.0
- Bun >= 1.0.0 (for development)
- Claude Code with MCP support
- macOS/Linux (POSIX-compliant)
## 🛠️ Development
```bash
# Development mode
bun run dev
# Build production bundle
bun run build
# Build and update hooks (RECOMMENDED for hook changes)
bun run build && bun link && claude-mem install --force
# Run tests
bun test # All tests
npm run test:integration # Integration tests
bun run test:unit # Unit tests only
# Install from source
bun run dev:install
# Live Memory Viewer
npm run memory-stream:server # Start SSE server on :3001
# Code quality
bun run lint
bun run format
```
## 🎨 Live Memory Viewer
Real-time slideshow of memories with SSE streaming:
1. Start the server: `npm run memory-stream:server`
2. Open the viewer at `src/ui/memory-stream/`
3. Auto-connects to `~/.claude-mem/claude-mem.db`
4. New memories appear instantly as they're created
Features:
- 📡 Live SSE streaming from SQLite WAL changes
- 🎬 Auto-slideshow (5s intervals)
- ⏸️ Pause/Resume with Space bar
- ⌨️ Keyboard navigation (←/→)
- 🎨 Cyberpunk neural network aesthetic
## 🔑 Key Design Decisions
**Storage Architecture**
- Direct ChromaDB writes in `store-memory.ts` command (no async syncing)
- Each atomic fact stored as separate document + full narrative document
- Hierarchical metadata: project, session, date, type, concepts, files
- SQLite for fast metadata queries, ChromaDB for semantic search
**Hook Infrastructure**
- Streaming hooks (<50ms overhead) capture real-time events
- Shared utilities in `hook-templates/shared/` for consistency
- Force overwrite on install to ensure latest hook code deploys
- Milliseconds in `config.json`, seconds in Claude settings
**Memory Compression**
- Agent SDK spawned asynchronously for tool response compression
- User prompts stored immediately without blocking
- SDK transcripts auto-deleted to keep UI clean
- 100:1 compression ratio maintained
**Search Strategy**
- Semantic search via query text (dates embedded in queries)
- Avoid complex metadata filters (causes ChromaDB errors)
- Always include project name in queries for isolation
- Multiple query phrasings for better coverage
## 🆘 Troubleshooting
```bash
claude-mem status # Check installation health
claude-mem doctor # Run full diagnostics
claude-mem install --force # Repair installation
claude-mem logs # View recent operations
```
## 📄 License
AGPL-3.0 - See LICENSE file for details
---
**Remember more. Repeat less.** 🧠✨
+23
View File
@@ -0,0 +1,23 @@
---
argument-hint: help | save [message] | remember [context] | (no args for help)
description: Manage claude-mem operations and memory context
allowed-tools: Bash(claude-mem:*), Bash(echo:*), Bash(cat:*)
---
## Claude-Mem Command Handler
### Check for help command first
!`[ -z "$ARGUMENTS" ] || [ "$ARGUMENTS" = "help" ] && printf '%s\n' '## 🧠 Claude-Mem Help' '' '**Available Commands:**' '' '• /claude-mem save [message] - Quick save of conversation overview' '• /claude-mem remember [query] - Search saved memories' '• /claude-mem help - Show this help' '' '**Quick Shortcuts:**' '• /save - Direct save' '• /remember - Direct search' '' '**About /save:**' 'Quick way to save an overview to claude-mem without processing the' 'entire transcript. Use this when you dont need a detailed archive,' 'just a summary of key points and decisions.' '' '**Optional Features (configure during install):**' '• Compress on /clear: Archives full transcript when clearing (off by default)' '• Session start: Loads recent memories when starting Claude Code' '' 'For more details: claude-mem --help' && exit 0`
### Process other commands
Handle claude-mem operation: $ARGUMENTS
If $ARGUMENTS starts with "save":
- Write an overview of the current conversation context
- Add it to claude-mem using the chroma MCP tools
- Save the overview using: `claude-mem save "your overview message"`
If $ARGUMENTS starts with "remember":
- Search claude-mem for relevant memories using the query
- Display the most relevant memories from previous sessions
- Use chroma_query_documents to find and present context
+7 -3
View File
@@ -1,3 +1,7 @@
Write an overview of the current conversation context and:
1. Add it to claude-mem using the chroma MCP tools
2. Save the overview using the claude-mem CLI tool: `claude-mem save "your overview message"`
---
allowed-tools: Bash
description: Write an overview and save with claude-mem
---
**Write an overview** of the current conversation context and:
1. **Add it to claude-mem** using the chroma MCP tools. Always use primitive types (strings, numbers, booleans) when calling MCP Chroma tools directly. Arrays should be comma-separated strings, and nested objects should be flattened.
2. **Save the overview to index** using the claude-mem CLI tool: `claude-mem save "your overview message"`
+585 -441
View File
File diff suppressed because one or more lines are too long
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env node
/**
* Post Tool Use Hook - Streaming SDK Version
*
* Feeds tool responses to the streaming SDK session for real-time processing.
* SDK decides what to store and calls bash commands directly.
*/
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { renderToolMessage, HOOK_CONFIG } from './shared/hook-prompt-renderer.js';
import { getProjectName } from './shared/path-resolver.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SESSION_DIR = path.join(process.env.HOME || '', '.claude-mem', 'sessions');
const HOOKS_LOG = path.join(process.env.HOME || '', '.claude-mem', 'logs', 'hooks.log');
function debugLog(message, data = {}) {
if (process.env.CLAUDE_MEM_DEBUG === 'true') {
const timestamp = new Date().toISOString();
const logLine = `[${timestamp}] HOOK DEBUG: ${message} ${JSON.stringify(data)}\n`;
try {
fs.appendFileSync(HOOKS_LOG, logLine);
process.stderr.write(logLine);
} catch (error) {
// Silent fail on log errors
}
}
}
// Removed: buildStreamingToolMessage function
// Now using centralized config from hook-prompt-renderer.js
// =============================================================================
// MAIN
// =============================================================================
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => { input += chunk; });
process.stdin.on('end', async () => {
let payload;
try {
payload = input ? JSON.parse(input) : {};
} catch (error) {
debugLog('PostToolUse: JSON parse error', { error: error.message });
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
process.exit(0);
}
const { tool_name, tool_response, prompt, cwd, timestamp } = payload;
const project = cwd ? getProjectName(cwd) : 'unknown';
// Return immediately - process async in background (don't block next tool)
console.log(JSON.stringify({ async: true, asyncTimeout: 180000 }));
try {
// Load SDK session info
const sessionFile = path.join(SESSION_DIR, `${project}_streaming.json`);
if (!fs.existsSync(sessionFile)) {
debugLog('PostToolUse: No streaming session found', { project });
process.exit(0);
}
const sessionData = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
const sdkSessionId = sessionData.sdkSessionId;
// Convert tool response to string
const toolResponseStr = typeof tool_response === 'string'
? tool_response
: JSON.stringify(tool_response);
// Build message for SDK using centralized config
const message = renderToolMessage({
toolName: tool_name,
toolResponse: toolResponseStr,
userPrompt: prompt || '',
timestamp: timestamp || new Date().toISOString()
});
// Send to SDK and wait for processing to complete using centralized config
const response = query({
prompt: message,
options: {
model: HOOK_CONFIG.sdk.model,
resume: sdkSessionId,
allowedTools: HOOK_CONFIG.sdk.allowedTools,
maxTokens: HOOK_CONFIG.sdk.maxTokensTool,
cwd // Must match where transcript was created
}
});
// Consume the stream to let SDK fully process
for await (const msg of response) {
debugLog('PostToolUse: SDK message', { type: msg.type, subtype: msg.subtype });
// SDK messages are structured differently than we expected
// - type: 'assistant' contains the assistant's response with content blocks
// - Content blocks can be text or tool_use
// - type: 'user' contains tool results
// - type: 'result' is the final summary
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text') {
debugLog('PostToolUse: SDK text', { text: block.text?.slice(0, 200) });
} else if (block.type === 'tool_use') {
debugLog('PostToolUse: SDK tool_use', {
tool: block.name,
input: JSON.stringify(block.input).slice(0, 200)
});
}
}
} else if (msg.type === 'user' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'tool_result') {
debugLog('PostToolUse: SDK tool_result', {
tool_use_id: block.tool_use_id,
content: typeof block.content === 'string' ? block.content.slice(0, 300) : JSON.stringify(block.content).slice(0, 300)
});
}
}
} else if (msg.type === 'result') {
debugLog('PostToolUse: SDK result', {
subtype: msg.subtype,
is_error: msg.is_error
});
}
}
debugLog('PostToolUse: SDK finished processing', { tool_name, sdkSessionId });
} catch (error) {
debugLog('PostToolUse: Error sending to SDK', { error: error.message });
}
// Exit cleanly after async processing completes
process.exit(0);
});
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env node
/**
* Session Start Hook (SDK Version)
*
* Calls the CLI to load relevant context from ChromaDB at session start.
*/
import { createHookResponse, debugLog } from './shared/hook-helpers.js';
// Read stdin
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
input += chunk;
});
process.stdin.on('end', async () => {
const payload = input ? JSON.parse(input) : {};
debugLog('SessionStart hook invoked (SDK version)', { cwd: payload.cwd });
const { cwd, source } = payload;
// Run on startup or /clear
if (source !== 'startup' && source !== 'clear') {
const response = createHookResponse('SessionStart', true);
console.log(JSON.stringify(response));
process.exit(0);
}
try {
// Call the CLI to load context
const { executeCliCommand } = await import('./shared/hook-helpers.js');
const result = await executeCliCommand('claude-mem', ['load-context', '--format', 'session-start']);
if (result.success && result.stdout) {
// Use the CLI output directly as context (it's already formatted)
const response = createHookResponse('SessionStart', true, {
context: result.stdout
});
console.log(JSON.stringify(response));
process.exit(0);
} else {
// Return without context
const response = createHookResponse('SessionStart', true);
console.log(JSON.stringify(response));
process.exit(0);
}
} catch (error) {
// Continue without context on error
const response = createHookResponse('SessionStart', true);
console.log(JSON.stringify(response));
process.exit(0);
}
});
@@ -39,30 +39,32 @@ export function createHookResponse(hookType, success, options = {}) {
if (hookType === 'SessionStart') {
if (success && options.context) {
return {
continue: true,
suppressOutput: true,
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext: options.context
}
};
} else if (success) {
// No context - just suppress output without any message
} else {
return {
continue: true,
suppressOutput: true
};
} else {
return {
continue: true, // Continue even on context loading failure
suppressOutput: true,
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext: `Context loading encountered an issue: ${options.error || 'Unknown error'}. Starting without previous context.`
}
};
}
}
if (hookType === 'UserPromptSubmit' || hookType === 'PostToolUse') {
return {
continue: true,
suppressOutput: true
};
}
if (hookType === 'Stop') {
return {
continue: true,
suppressOutput: true
};
}
// Generic response for unknown hook types
return {
@@ -115,9 +117,10 @@ export function formatSessionStartContext(contextData) {
*/
export async function executeCliCommand(command, args = [], options = {}) {
return new Promise((resolve) => {
const { input, ...spawnOptions } = options;
const process = spawn(command, args, {
stdio: ['ignore', 'pipe', 'pipe'],
...options
stdio: ['pipe', 'pipe', 'pipe'],
...spawnOptions
});
let stdout = '';
@@ -135,6 +138,13 @@ export async function executeCliCommand(command, args = [], options = {}) {
});
}
if (input && process.stdin) {
process.stdin.write(input);
process.stdin.end();
} else if (process.stdin) {
process.stdin.end();
}
process.on('close', (code) => {
resolve({
stdout: stdout.trim(),
@@ -224,4 +234,4 @@ export function debugLog(message, data = {}) {
const timestamp = new Date().toISOString();
console.error(`[${timestamp}] HOOK DEBUG: ${message}`, data);
}
}
}
@@ -0,0 +1,278 @@
// src/prompts/hook-prompts.config.ts
var HOOK_CONFIG = {
maxUserPromptLength: 200,
maxToolResponseLength: 20000,
sdk: {
model: "claude-sonnet-4-5",
allowedTools: ["Bash"],
maxTokensSystem: 8192,
maxTokensTool: 8192,
maxTokensEnd: 2048
}
};
var SYSTEM_PROMPT = `You are a semantic memory compressor for claude-mem. You process tool responses from an active Claude Code session and store the important ones as searchable, hierarchical memories.
# SESSION CONTEXT
- Project: {{project}}
- Session: {{sessionId}}
- Date: {{date}}
- User Request: "{{userPrompt}}"
# YOUR JOB
## FIRST: Generate Session Title
IMMEDIATELY generate a title and subtitle for this session based on the user request.
Use this bash command:
\`\`\`bash
claude-mem update-session-metadata \\
--project "{{project}}" \\
--session "{{sessionId}}" \\
--title "Short title (3-6 words)" \\
--subtitle "One sentence description (max 20 words)"
\`\`\`
Example for "Help me add dark mode to my app":
- Title: "Dark Mode Implementation"
- Subtitle: "Adding theme toggle and dark color scheme support to the application"
## THEN: Process Tool Responses
You will receive a stream of tool responses. For each one:
1. ANALYZE: Does this contain information worth remembering?
2. DECIDE: Should I store this or skip it?
3. EXTRACT: What are the key semantic concepts?
4. DECOMPOSE: Break into title + subtitle + atomic facts + narrative
5. STORE: Use bash to save the hierarchical memory
6. TRACK: Keep count of stored memories (001, 002, 003...)
# WHAT TO STORE
Store these:
- File contents with logic, algorithms, or patterns
- Search results revealing project structure
- Build errors or test failures with context
- Code revealing architecture or design decisions
- Git diffs with significant changes
- Command outputs showing system state
Skip these:
- Simple status checks (git status with no changes)
- Trivial edits (one-line config changes)
- Repeated operations
- Binary data or noise
- Anything without semantic value
# HIERARCHICAL MEMORY FORMAT
Each memory has FOUR components:
## 1. TITLE (3-8 words)
A scannable headline that captures the core action or topic.
Examples:
- "SDK Transcript Cleanup Implementation"
- "Hook System Architecture Analysis"
- "ChromaDB Migration Planning"
## 2. SUBTITLE (max 24 words)
A concise, memorable summary that captures the essence of the change.
Examples:
- "Automatic transcript cleanup after SDK session completion prevents memory conversations from appearing in UI history"
- "Four lifecycle hooks coordinate session events: start, prompt submission, tool processing, and completion"
- "Data migration from SQLite to ChromaDB enables semantic search across compressed conversation memories"
Guidelines:
- Clear and descriptive
- Focus on the outcome or benefit
- Use active voice when possible
- Keep it professional and informative
## 3. ATOMIC FACTS (3-7 facts, 50-150 chars each)
Individual, searchable statements that can be vector-embedded separately.
Each fact is ONE specific piece of information.
Examples:
- "stop-streaming.js: Auto-deletes SDK transcripts after completion"
- "Path format: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl"
- "Uses fs.unlink with graceful error handling for missing files"
- "Checks two transcript path formats for backward compatibility"
Guidelines:
- Start with filename or component when relevant
- Be specific: include paths, function names, actual values
- Each fact stands alone (no pronouns like "it" or "this")
- 50-150 characters target
- Focus on searchable technical details
## 4. NARRATIVE (512-1024 tokens, same as current format)
The full contextual story for deep dives:
"In the {{project}} project, [action taken]. [Technical details: files, functions, concepts]. [Why this matters]."
This is the detailed explanation for when someone needs full context.
# STORAGE COMMAND FORMAT
Store using this EXACT bash command structure:
\`\`\`bash
claude-mem store-memory \\
--id "{{project}}_{{sessionId}}_{{date}}_001" \\
--title "Your Title Here" \\
--subtitle "Your concise subtitle here" \\
--facts '["Fact 1 here", "Fact 2 here", "Fact 3 here"]' \\
--concepts '["concept1", "concept2", "concept3"]' \\
--files '["path/to/file1.js", "path/to/file2.ts"]' \\
--project "{{project}}" \\
--session "{{sessionId}}" \\
--date "{{date}}"
\`\`\`
CRITICAL FORMATTING RULES:
- Use single quotes around JSON arrays: --facts '["item1", "item2"]'
- Use double quotes inside the JSON arrays: "item"
- Use double quotes around simple string values: --title "Title"
- Escape any quotes in the content properly
- Sequential numbering: 001, 002, 003, etc.
Concepts: 2-5 broad categories (e.g., "hooks", "storage", "async-processing")
Files: Actual file paths touched (e.g., "hooks/stop-streaming.js")
# EXAMPLE MEMORY
Tool response shows: [Read file hooks/stop-streaming.js with 167 lines of code implementing SDK cleanup]
Your storage command:
\`\`\`bash
claude-mem store-memory \\
--id "claude-mem_abc123_2025-10-01_001" \\
--title "SDK Transcript Auto-Cleanup" \\
--subtitle "Automatic deletion of SDK transcripts after completion prevents memory conversations from appearing in UI history" \\
--facts '["stop-streaming.js: Deletes SDK transcript after overview generation", "Path: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl", "Uses fs.unlink with error handling for missing files", "Prevents memory conversations from polluting Claude Code UI"]' \\
--concepts '["cleanup", "SDK-lifecycle", "UX", "file-management"]' \\
--files '["hooks/stop-streaming.js"]' \\
--project "claude-mem" \\
--session "abc123" \\
--date "2025-10-01"
\`\`\`
# STATE TRACKING
CRITICAL: Keep track of your memory counter across all tool messages.
- Start at 001
- Increment for each stored memory
- Never repeat numbers
- Each session has separate numbering
# SESSION END
At the end (when I send "SESSION ENDING"), generate an overview using:
\`\`\`bash
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "2-3 sentence overview"
\`\`\`
# IMPORTANT REMINDERS
- You're processing a DIFFERENT Claude Code session (not your own)
- Use Bash tool to call claude-mem commands
- Keep subtitles clear and informative (max 24 words)
- Each fact is ONE specific thing (not multiple ideas)
- Be selective - quality over quantity
- Always increment memory numbers
- Facts should be searchable (specific file names, paths, functions)
Ready for tool responses.`;
var TOOL_MESSAGE = `# Tool Response {{timeFormatted}}
Tool: {{toolName}}
User Context: "{{userPrompt}}"
\`\`\`
{{toolResponse}}
\`\`\`
Analyze and store if meaningful.`;
var END_MESSAGE = `# SESSION ENDING
Review our entire conversation. Generate a concise 2-3 sentence overview of what was accomplished.
Store it using Bash:
\`\`\`bash
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "YOUR_OVERVIEW_HERE"
\`\`\`
Focus on: what was done, current state, key decisions, outcomes.`;
var PROMPTS = {
system: SYSTEM_PROMPT,
tool: TOOL_MESSAGE,
end: END_MESSAGE
};
// src/prompts/hook-prompt-renderer.ts
function substituteVariables(template, variables) {
let result = template;
for (const [key, value] of Object.entries(variables)) {
const placeholder = `{{${key}}}`;
result = result.split(placeholder).join(value);
}
return result;
}
function truncate(text, maxLength) {
if (text.length <= maxLength)
return text;
return text.slice(0, maxLength) + (text.length > maxLength ? "..." : "");
}
function formatTime(timestamp) {
const timePart = timestamp.split("T")[1];
if (!timePart)
return "";
return timePart.slice(0, 8);
}
function renderSystemPrompt(variables) {
const userPromptTruncated = truncate(variables.userPrompt, HOOK_CONFIG.maxUserPromptLength);
return substituteVariables(PROMPTS.system, {
project: variables.project,
sessionId: variables.sessionId,
date: variables.date,
userPrompt: userPromptTruncated
});
}
function renderToolMessage(variables) {
const userPromptTruncated = truncate(variables.userPrompt, HOOK_CONFIG.maxUserPromptLength);
const toolResponseTruncated = truncate(variables.toolResponse, HOOK_CONFIG.maxToolResponseLength);
const timeFormatted = formatTime(variables.timestamp);
return substituteVariables(PROMPTS.tool, {
toolName: variables.toolName,
toolResponse: toolResponseTruncated,
userPrompt: userPromptTruncated,
timestamp: variables.timestamp,
timeFormatted
});
}
function renderEndMessage(variables) {
return substituteVariables(PROMPTS.end, {
project: variables.project,
sessionId: variables.sessionId
});
}
function renderPrompt(type, variables) {
switch (type) {
case "system":
return renderSystemPrompt(variables);
case "tool":
return renderToolMessage(variables);
case "end":
return renderEndMessage(variables);
default:
throw new Error(`Unknown prompt type: ${type}`);
}
}
export {
renderToolMessage,
renderSystemPrompt,
renderPrompt,
renderEndMessage,
PROMPTS,
HOOK_CONFIG
};
@@ -0,0 +1,217 @@
// src/prompts/hook-prompts.config.ts
var HOOK_CONFIG = {
maxUserPromptLength: 200,
maxToolResponseLength: 20000,
sdk: {
model: "claude-sonnet-4-5",
allowedTools: ["Bash"],
maxTokensSystem: 8192,
maxTokensTool: 8192,
maxTokensEnd: 2048
}
};
var SYSTEM_PROMPT = `You are a semantic memory compressor for claude-mem. You process tool responses from an active Claude Code session and store the important ones as searchable, hierarchical memories.
# SESSION CONTEXT
- Project: {{project}}
- Session: {{sessionId}}
- Date: {{date}}
- User Request: "{{userPrompt}}"
# YOUR JOB
## FIRST: Generate Session Title
IMMEDIATELY generate a title and subtitle for this session based on the user request.
Use this bash command:
\`\`\`bash
claude-mem update-session-metadata \\
--project "{{project}}" \\
--session "{{sessionId}}" \\
--title "Short title (3-6 words)" \\
--subtitle "One sentence description (max 20 words)"
\`\`\`
Example for "Help me add dark mode to my app":
- Title: "Dark Mode Implementation"
- Subtitle: "Adding theme toggle and dark color scheme support to the application"
## THEN: Process Tool Responses
You will receive a stream of tool responses. For each one:
1. ANALYZE: Does this contain information worth remembering?
2. DECIDE: Should I store this or skip it?
3. EXTRACT: What are the key semantic concepts?
4. DECOMPOSE: Break into title + subtitle + atomic facts + narrative
5. STORE: Use bash to save the hierarchical memory
6. TRACK: Keep count of stored memories (001, 002, 003...)
# WHAT TO STORE
Store these:
- File contents with logic, algorithms, or patterns
- Search results revealing project structure
- Build errors or test failures with context
- Code revealing architecture or design decisions
- Git diffs with significant changes
- Command outputs showing system state
Skip these:
- Simple status checks (git status with no changes)
- Trivial edits (one-line config changes)
- Repeated operations
- Binary data or noise
- Anything without semantic value
# HIERARCHICAL MEMORY FORMAT
Each memory has FOUR components:
## 1. TITLE (3-8 words)
A scannable headline that captures the core action or topic.
Examples:
- "SDK Transcript Cleanup Implementation"
- "Hook System Architecture Analysis"
- "ChromaDB Migration Planning"
## 2. SUBTITLE (max 24 words)
A concise, memorable summary that captures the essence of the change.
Examples:
- "Automatic transcript cleanup after SDK session completion prevents memory conversations from appearing in UI history"
- "Four lifecycle hooks coordinate session events: start, prompt submission, tool processing, and completion"
- "Data migration from SQLite to ChromaDB enables semantic search across compressed conversation memories"
Guidelines:
- Clear and descriptive
- Focus on the outcome or benefit
- Use active voice when possible
- Keep it professional and informative
## 3. ATOMIC FACTS (3-7 facts, 50-150 chars each)
Individual, searchable statements that can be vector-embedded separately.
Each fact is ONE specific piece of information.
Examples:
- "stop-streaming.js: Auto-deletes SDK transcripts after completion"
- "Path format: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl"
- "Uses fs.unlink with graceful error handling for missing files"
- "Checks two transcript path formats for backward compatibility"
Guidelines:
- Start with filename or component when relevant
- Be specific: include paths, function names, actual values
- Each fact stands alone (no pronouns like "it" or "this")
- 50-150 characters target
- Focus on searchable technical details
## 4. NARRATIVE (512-1024 tokens, same as current format)
The full contextual story for deep dives:
"In the {{project}} project, [action taken]. [Technical details: files, functions, concepts]. [Why this matters]."
This is the detailed explanation for when someone needs full context.
# STORAGE COMMAND FORMAT
Store using this EXACT bash command structure:
\`\`\`bash
claude-mem store-memory \\
--id "{{project}}_{{sessionId}}_{{date}}_001" \\
--title "Your Title Here" \\
--subtitle "Your concise subtitle here" \\
--facts '["Fact 1 here", "Fact 2 here", "Fact 3 here"]' \\
--concepts '["concept1", "concept2", "concept3"]' \\
--files '["path/to/file1.js", "path/to/file2.ts"]' \\
--project "{{project}}" \\
--session "{{sessionId}}" \\
--date "{{date}}"
\`\`\`
CRITICAL FORMATTING RULES:
- Use single quotes around JSON arrays: --facts '["item1", "item2"]'
- Use double quotes inside the JSON arrays: "item"
- Use double quotes around simple string values: --title "Title"
- Escape any quotes in the content properly
- Sequential numbering: 001, 002, 003, etc.
Concepts: 2-5 broad categories (e.g., "hooks", "storage", "async-processing")
Files: Actual file paths touched (e.g., "hooks/stop-streaming.js")
# EXAMPLE MEMORY
Tool response shows: [Read file hooks/stop-streaming.js with 167 lines of code implementing SDK cleanup]
Your storage command:
\`\`\`bash
claude-mem store-memory \\
--id "claude-mem_abc123_2025-10-01_001" \\
--title "SDK Transcript Auto-Cleanup" \\
--subtitle "Automatic deletion of SDK transcripts after completion prevents memory conversations from appearing in UI history" \\
--facts '["stop-streaming.js: Deletes SDK transcript after overview generation", "Path: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl", "Uses fs.unlink with error handling for missing files", "Prevents memory conversations from polluting Claude Code UI"]' \\
--concepts '["cleanup", "SDK-lifecycle", "UX", "file-management"]' \\
--files '["hooks/stop-streaming.js"]' \\
--project "claude-mem" \\
--session "abc123" \\
--date "2025-10-01"
\`\`\`
# STATE TRACKING
CRITICAL: Keep track of your memory counter across all tool messages.
- Start at 001
- Increment for each stored memory
- Never repeat numbers
- Each session has separate numbering
# SESSION END
At the end (when I send "SESSION ENDING"), generate an overview using:
\`\`\`bash
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "2-3 sentence overview"
\`\`\`
# IMPORTANT REMINDERS
- You're processing a DIFFERENT Claude Code session (not your own)
- Use Bash tool to call claude-mem commands
- Keep subtitles clear and informative (max 24 words)
- Each fact is ONE specific thing (not multiple ideas)
- Be selective - quality over quantity
- Always increment memory numbers
- Facts should be searchable (specific file names, paths, functions)
Ready for tool responses.`;
var TOOL_MESSAGE = `# Tool Response {{timeFormatted}}
Tool: {{toolName}}
User Context: "{{userPrompt}}"
\`\`\`
{{toolResponse}}
\`\`\`
Analyze and store if meaningful.`;
var END_MESSAGE = `# SESSION ENDING
Review our entire conversation. Generate a concise 2-3 sentence overview of what was accomplished.
Store it using Bash:
\`\`\`bash
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "YOUR_OVERVIEW_HERE"
\`\`\`
Focus on: what was done, current state, key decisions, outcomes.`;
var PROMPTS = {
system: SYSTEM_PROMPT,
tool: TOOL_MESSAGE,
end: END_MESSAGE
};
export {
TOOL_MESSAGE,
SYSTEM_PROMPT,
PROMPTS,
HOOK_CONFIG,
END_MESSAGE
};
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env node
/**
* Path resolver utility for Claude Memory hooks
* Provides proper path handling using environment variables
*/
import { join, basename } from 'path';
import { homedir } from 'os';
/**
* Gets the base data directory for claude-mem
* @returns {string} Data directory path
*/
export function getDataDir() {
return process.env.CLAUDE_MEM_DATA_DIR || join(homedir(), '.claude-mem');
}
/**
* Gets the settings file path
* @returns {string} Settings file path
*/
export function getSettingsPath() {
return join(getDataDir(), 'settings.json');
}
/**
* Gets the archives directory path
* @returns {string} Archives directory path
*/
export function getArchivesDir() {
return process.env.CLAUDE_MEM_ARCHIVES_DIR || join(getDataDir(), 'archives');
}
/**
* Gets the logs directory path
* @returns {string} Logs directory path
*/
export function getLogsDir() {
return process.env.CLAUDE_MEM_LOGS_DIR || join(getDataDir(), 'logs');
}
/**
* Gets the compact flag file path
* @returns {string} Compact flag file path
*/
export function getCompactFlagPath() {
return join(getDataDir(), '.compact-running');
}
/**
* Gets the claude-mem package root directory
* @returns {Promise<string>} Package root path
*/
export async function getPackageRoot() {
// Method 1: Check if we're running from development
const devPath = join(homedir(), 'Scripts', 'claude-mem-source');
const { existsSync } = await import('fs');
if (existsSync(join(devPath, 'package.json'))) {
return devPath;
}
// Method 2: Follow the binary symlink
try {
const { execSync } = await import('child_process');
const { realpathSync } = await import('fs');
const binPath = execSync('which claude-mem', { encoding: 'utf8' }).trim();
const realBinPath = realpathSync(binPath);
// Binary is typically at package_root/dist/claude-mem.min.js
return join(realBinPath, '../..');
} catch {}
throw new Error('Cannot locate claude-mem package root');
}
/**
* Gets the project root directory
* Uses CLAUDE_PROJECT_DIR environment variable if available, otherwise falls back to cwd
* @returns {string} Project root path
*/
export function getProjectRoot() {
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
}
/**
* Derives project name from CLAUDE_PROJECT_DIR or current working directory
* Priority: CLAUDE_PROJECT_DIR > cwd parameter > process.cwd()
* @param {string} [cwd] - Optional current working directory from hook payload
* @returns {string} Project name (basename of project directory)
*/
export function getProjectName(cwd) {
const projectRoot = process.env.CLAUDE_PROJECT_DIR || cwd || process.cwd();
return basename(projectRoot);
}
/**
* Gets all common paths used by hooks
* @returns {Object} Object containing all common paths
*/
export function getPaths() {
return {
dataDir: getDataDir(),
settingsPath: getSettingsPath(),
archivesDir: getArchivesDir(),
logsDir: getLogsDir(),
compactFlagPath: getCompactFlagPath()
};
}
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env node
/**
* Stop Hook - Simple Orchestrator
*
* Signals session end to SDK, which generates and stores the overview via CLI.
* Cleans up SDK transcript from UI.
*/
import path from 'path';
import fs from 'fs';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { renderEndMessage, HOOK_CONFIG } from './shared/hook-prompt-renderer.js';
import { getProjectName } from './shared/path-resolver.js';
const SESSION_DIR = path.join(process.env.HOME || '', '.claude-mem', 'sessions');
const HOOKS_LOG = path.join(process.env.HOME || '', '.claude-mem', 'logs', 'hooks.log');
function debugLog(message, data = {}) {
if (process.env.CLAUDE_MEM_DEBUG === 'true') {
const timestamp = new Date().toISOString();
const logLine = `[${timestamp}] HOOK DEBUG: ${message} ${JSON.stringify(data)}\n`;
try {
fs.appendFileSync(HOOKS_LOG, logLine);
process.stderr.write(logLine);
} catch (error) {
// Silent fail on log errors
}
}
}
// =============================================================================
// MAIN
// =============================================================================
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => { input += chunk; });
process.stdin.on('end', async () => {
let payload;
try {
payload = input ? JSON.parse(input) : {};
} catch (error) {
debugLog('Stop: JSON parse error', { error: error.message });
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
process.exit(0);
}
const { cwd } = payload;
const project = cwd ? getProjectName(cwd) : 'unknown';
// Return immediately with async mode
console.log(JSON.stringify({ async: true, asyncTimeout: 180000 }));
try {
// Load SDK session info
const sessionFile = path.join(SESSION_DIR, `${project}_streaming.json`);
if (!fs.existsSync(sessionFile)) {
debugLog('Stop: No streaming session found', { project });
process.exit(0);
}
const sessionData = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
const sdkSessionId = sessionData.sdkSessionId;
const claudeSessionId = sessionData.claudeSessionId;
debugLog('Stop: Ending SDK session', { sdkSessionId, claudeSessionId });
// Build end message - SDK will call `claude-mem store-overview` and `chroma_add_documents`
const message = renderEndMessage({
project,
sessionId: claudeSessionId
});
// Send end message and wait for SDK to complete
const response = query({
prompt: message,
options: {
model: HOOK_CONFIG.sdk.model,
resume: sdkSessionId,
allowedTools: HOOK_CONFIG.sdk.allowedTools,
maxTokens: HOOK_CONFIG.sdk.maxTokensEnd,
cwd // Must match where transcript was created
}
});
// Consume the response stream (wait for SDK to finish storing via CLI)
for await (const msg of response) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'tool_use') {
debugLog('Stop: SDK tool call', { tool: block.name });
}
}
}
}
debugLog('Stop: SDK session ended', { sdkSessionId });
// Delete SDK memories transcript from Claude Code UI
const sanitizedCwd = cwd.replace(/\//g, '-');
const projectsDir = path.join(process.env.HOME, '.claude', 'projects', sanitizedCwd);
const memoriesTranscriptPath = path.join(projectsDir, `${sdkSessionId}.jsonl`);
if (fs.existsSync(memoriesTranscriptPath)) {
fs.unlinkSync(memoriesTranscriptPath);
debugLog('Stop: Cleaned up memories transcript', { memoriesTranscriptPath });
}
// Clean up session file
fs.unlinkSync(sessionFile);
debugLog('Stop: Session ended and cleaned up', { project });
} catch (error) {
debugLog('Stop: Error ending session', { error: error.message });
}
// Exit cleanly after async processing completes
process.exit(0);
});
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env node
/**
* User Prompt Submit Hook - Streaming SDK Version
*
* Starts a streaming SDK session that will process tool responses in real-time.
* Saves the SDK session ID for post-tool-use and stop hooks to resume.
*/
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { renderSystemPrompt, HOOK_CONFIG } from './shared/hook-prompt-renderer.js';
import { getProjectName } from './shared/path-resolver.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SESSION_DIR = path.join(process.env.HOME || '', '.claude-mem', 'sessions');
const HOOKS_LOG = path.join(process.env.HOME || '', '.claude-mem', 'logs', 'hooks.log');
function debugLog(message, data = {}) {
if (process.env.CLAUDE_MEM_DEBUG === 'true') {
const timestamp = new Date().toISOString();
const logLine = `[${timestamp}] HOOK DEBUG: ${message} ${JSON.stringify(data)}\n`;
try {
fs.appendFileSync(HOOKS_LOG, logLine);
process.stderr.write(logLine);
} catch (error) {
// Silent fail on log errors
}
}
}
// Removed: buildStreamingSystemPrompt function
// Now using centralized config from hook-prompt-renderer.js
// =============================================================================
// MAIN
// =============================================================================
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => { input += chunk; });
process.stdin.on('end', async () => {
let payload;
try {
payload = input ? JSON.parse(input) : {};
} catch (error) {
debugLog('UserPromptSubmit: JSON parse error', { error: error.message });
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
process.exit(0);
}
const { prompt, cwd, session_id, timestamp } = payload;
const project = cwd ? getProjectName(cwd) : 'unknown';
const date = timestamp ? timestamp.split('T')[0] : new Date().toISOString().split('T')[0];
debugLog('UserPromptSubmit: Starting streaming session', { project, session_id });
// Generate title and subtitle non-blocking
if (prompt && session_id && project) {
import('child_process').then(({ spawn }) => {
const titleProcess = spawn('claude-mem', [
'generate-title',
'--save',
'--project', project,
'--session', session_id,
prompt
], {
stdio: 'ignore',
detached: true
});
titleProcess.unref();
}).catch(error => {
debugLog('UserPromptSubmit: Error spawning title generator', { error: error.message });
});
}
try {
// Build system prompt using centralized config
const systemPrompt = renderSystemPrompt({
project,
sessionId: session_id,
date,
userPrompt: prompt || ''
});
// Start SDK session using centralized config
const response = query({
prompt: systemPrompt,
options: {
model: HOOK_CONFIG.sdk.model,
allowedTools: HOOK_CONFIG.sdk.allowedTools,
maxTokens: HOOK_CONFIG.sdk.maxTokensSystem,
cwd // SDK will save transcript in this directory
}
});
// Wait for session ID from init message and consume entire stream
let sdkSessionId = null;
for await (const message of response) {
if (message.type === 'system' && message.subtype === 'init') {
sdkSessionId = message.session_id;
debugLog('UserPromptSubmit: Got SDK session ID', { sdkSessionId });
}
// Don't break - consume entire stream so transcript gets written
}
if (sdkSessionId) {
// Save session info for other hooks
fs.mkdirSync(SESSION_DIR, { recursive: true });
const sessionFile = path.join(SESSION_DIR, `${project}_streaming.json`);
fs.writeFileSync(sessionFile, JSON.stringify({
sdkSessionId,
claudeSessionId: session_id,
project,
startedAt: timestamp,
date
}, null, 2));
debugLog('UserPromptSubmit: SDK session started', { sdkSessionId, sessionFile });
}
} catch (error) {
debugLog('UserPromptSubmit: Error starting SDK session', { error: error.message });
}
// Return success to Claude Code
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
process.exit(0);
});
-86
View File
@@ -1,86 +0,0 @@
#!/usr/bin/env node
/**
* Pre-Compact Hook for Claude Memory System
*
* Updated to use the centralized PromptOrchestrator and HookTemplates system.
* This hook validates the pre-compact request and executes compression using
* standardized response templates for consistent Claude Code integration.
*/
import { loadCliCommand } from './shared/config-loader.js';
import { getLogsDir } from './shared/path-resolver.js';
import {
createHookResponse,
executeCliCommand,
validateHookPayload,
debugLog
} from './shared/hook-helpers.js';
// Set up stdin immediately before any async operations
process.stdin.setEncoding('utf8');
process.stdin.resume(); // Explicitly enter flowing mode to prevent data loss
// Read input from stdin
let input = '';
process.stdin.on('data', chunk => {
input += chunk;
});
process.stdin.on('end', async () => {
try {
// Load CLI command inside try-catch to handle config errors properly
const cliCommand = loadCliCommand();
const payload = JSON.parse(input);
debugLog('Pre-compact hook started', { payload });
// Validate payload using centralized validation
const validation = validateHookPayload(payload, 'PreCompact');
if (!validation.valid) {
const response = createHookResponse('PreCompact', false, { reason: validation.error });
debugLog('Validation failed', { response });
// Exit silently - validation failure is expected flow control
process.exit(0);
}
// Check for environment-based blocking conditions
if (payload.trigger === 'auto' && process.env.DISABLE_AUTO_COMPRESSION === 'true') {
const response = createHookResponse('PreCompact', false, {
reason: 'Auto-compression disabled by configuration'
});
debugLog('Auto-compression disabled', { response });
// Exit silently - disabled compression is expected flow control
process.exit(0);
}
// Execute compression using standardized CLI execution helper
debugLog('Executing compression command', {
command: cliCommand,
args: ['compress', payload.transcript_path]
});
const result = await executeCliCommand(cliCommand, ['compress', payload.transcript_path]);
if (!result.success) {
const response = createHookResponse('PreCompact', false, {
reason: `Compression failed: ${result.stderr || 'Unknown error'}`
});
debugLog('Compression command failed', { stderr: result.stderr, response });
console.log(`claude-mem error: compression failed, see logs at ${getLogsDir()}`);
process.exit(1); // Exit with error code for actual compression failure
}
// Success - exit silently (suppressOutput is true)
debugLog('Compression completed successfully');
process.exit(0);
} catch (error) {
const response = createHookResponse('PreCompact', false, {
reason: `Hook execution error: ${error.message}`
});
debugLog('Pre-compact hook error', { error: error.message, response });
console.log(`claude-mem error: hook failed, see logs at ${getLogsDir()}`);
process.exit(1);
}
});
-61
View File
@@ -1,61 +0,0 @@
#!/usr/bin/env node
/**
* Session End Hook - Handles session end events including /clear
*/
import { loadCliCommand } from './shared/config-loader.js';
import { getSettingsPath, getArchivesDir } from './shared/path-resolver.js';
import { execSync } from 'child_process';
import { existsSync, readFileSync } from 'fs';
const cliCommand = loadCliCommand();
// Check if save-on-clear is enabled
function isSaveOnClearEnabled() {
const settingsPath = getSettingsPath();
if (existsSync(settingsPath)) {
try {
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
return settings.saveMemoriesOnClear === true;
} catch (error) {
return false;
}
}
return false;
}
// Set up stdin immediately before any async operations
process.stdin.setEncoding('utf8');
process.stdin.resume(); // Explicitly enter flowing mode to prevent data loss
// Read input
let input = '';
process.stdin.on('data', chunk => {
input += chunk;
});
process.stdin.on('end', async () => {
const data = JSON.parse(input);
// Check if this is a clear event and save-on-clear is enabled
if (data.reason === 'clear' && isSaveOnClearEnabled()) {
console.error('🧠 Saving memories before clearing context...');
try {
// Use the CLI to compress current transcript
execSync(`${cliCommand} compress --output ${getArchivesDir()}`, {
stdio: 'inherit',
env: { ...process.env, CLAUDE_MEM_SILENT: 'true' }
});
console.error('✅ Memories saved successfully');
} catch (error) {
console.error('[session-end] Failed to save memories:', error.message);
// Don't block the clear operation if memory saving fails
}
}
// Always continue
console.log(JSON.stringify({ continue: true }));
});
-170
View File
@@ -1,170 +0,0 @@
#!/usr/bin/env node
/**
* Session Start Hook - Load context when Claude Code starts
*
* Updated to use the centralized PromptOrchestrator and HookTemplates system.
* This hook loads previous session context using standardized formatting and
* provides rich context messages for Claude Code integration.
*/
import path from 'path';
import { loadCliCommand } from './shared/config-loader.js';
import {
createHookResponse,
formatSessionStartContext,
executeCliCommand,
parseContextData,
validateHookPayload,
debugLog
} from './shared/hook-helpers.js';
const cliCommand = loadCliCommand();
// Set up stdin immediately before any async operations
process.stdin.setEncoding('utf8');
process.stdin.resume(); // Explicitly enter flowing mode to prevent data loss
// Read input from stdin
let input = '';
process.stdin.on('data', chunk => {
input += chunk;
});
process.stdin.on('end', async () => {
try {
const payload = JSON.parse(input);
debugLog('Session start hook started', { payload });
// Validate payload using centralized validation
const validation = validateHookPayload(payload, 'SessionStart');
if (!validation.valid) {
debugLog('Payload validation failed', { error: validation.error });
// For session start, continue even with invalid payload but log the error
const response = createHookResponse('SessionStart', false, {
error: `Payload validation failed: ${validation.error}`
});
console.log(JSON.stringify(response));
process.exit(0);
}
// Skip load-context when source is "resume" to avoid duplicate context
if (payload.source === 'resume') {
debugLog('Skipping load-context for resume source');
// Output valid JSON response with suppressOutput for resume
const response = createHookResponse('SessionStart', true);
console.log(JSON.stringify(response));
process.exit(0);
}
// Extract project name from current working directory
const projectName = path.basename(process.cwd());
// Load context using standardized CLI execution helper
const contextResult = await executeCliCommand(cliCommand, [
'load-context',
'--format', 'session-start',
'--project', projectName
]);
if (!contextResult.success) {
debugLog('Context loading failed', { stderr: contextResult.stderr });
// Don't fail the session start, just provide error context
const response = createHookResponse('SessionStart', false, {
error: contextResult.stderr || 'Failed to load context'
});
console.log(JSON.stringify(response));
process.exit(0);
}
const rawContext = contextResult.stdout;
debugLog('Raw context loaded', { contextLength: rawContext.length });
// Check if the output is actually an error message (starts with ❌)
if (rawContext && rawContext.trim().startsWith('❌')) {
debugLog('Detected error message in stdout', { rawContext });
// Extract the clean error message without the emoji and format
const errorMatch = rawContext.match(/❌\s*[^:]+:\s*([^\n]+)(?:\n\n💡\s*(.+))?/);
let errorMsg = 'No previous memories found';
let suggestion = '';
if (errorMatch) {
errorMsg = errorMatch[1] || errorMsg;
suggestion = errorMatch[2] || '';
}
// Create a clean response without duplicating the error formatting
const response = createHookResponse('SessionStart', false, {
error: errorMsg + (suggestion ? `. ${suggestion}` : '')
});
console.log(JSON.stringify(response));
process.exit(0);
}
if (!rawContext || !rawContext.trim()) {
debugLog('No context available, creating empty response');
// No context available - use standardized empty response
const response = createHookResponse('SessionStart', true);
console.log(JSON.stringify(response));
process.exit(0);
}
// Parse context data and format using centralized templates
const contextData = parseContextData(rawContext);
contextData.projectName = projectName;
// If we have raw context (not structured data), use it directly
let formattedContext;
if (contextData.rawContext) {
formattedContext = contextData.rawContext;
} else {
// Use standardized formatting for structured context
formattedContext = formatSessionStartContext(contextData);
}
debugLog('Context formatted successfully', {
memoryCount: contextData.memoryCount,
hasStructuredData: !contextData.rawContext
});
// Create standardized session start response using HookTemplates
const response = createHookResponse('SessionStart', true, {
context: formattedContext
});
console.log(JSON.stringify(response));
process.exit(0);
} catch (error) {
debugLog('Session start hook error', { error: error.message });
// Even on error, continue the session with error information
const response = createHookResponse('SessionStart', false, {
error: `Hook execution error: ${error.message}`
});
console.log(JSON.stringify(response));
process.exit(0);
}
});
/**
* Extracts project name from transcript path
* @param {string} transcriptPath - Path to transcript file
* @returns {string|null} Extracted project name or null
*/
function extractProjectName(transcriptPath) {
if (!transcriptPath) return null;
// Look for project pattern: /path/to/PROJECT_NAME/.claude/
// Need to get PROJECT_NAME, not the parent directory
const parts = transcriptPath.split(path.sep);
const claudeIndex = parts.indexOf('.claude');
if (claudeIndex > 0) {
// Get the directory immediately before .claude
return parts[claudeIndex - 1];
}
// Fall back to directory containing the transcript
const dir = path.dirname(transcriptPath);
return path.basename(dir);
}
-54
View File
@@ -1,54 +0,0 @@
#!/usr/bin/env node
/**
* Path resolver utility for Claude Memory hooks
* Provides proper path handling using environment variables
*/
import { join } from 'path';
import { homedir } from 'os';
/**
* Gets the base data directory for claude-mem
* @returns {string} Data directory path
*/
export function getDataDir() {
return process.env.CLAUDE_MEM_DATA_DIR || join(homedir(), '.claude-mem');
}
/**
* Gets the settings file path
* @returns {string} Settings file path
*/
export function getSettingsPath() {
return join(getDataDir(), 'settings.json');
}
/**
* Gets the archives directory path
* @returns {string} Archives directory path
*/
export function getArchivesDir() {
return process.env.CLAUDE_MEM_ARCHIVES_DIR || join(getDataDir(), 'archives');
}
/**
* Gets the logs directory path
* @returns {string} Logs directory path
*/
export function getLogsDir() {
return process.env.CLAUDE_MEM_LOGS_DIR || join(getDataDir(), 'logs');
}
/**
* Gets all common paths used by hooks
* @returns {Object} Object containing all common paths
*/
export function getPaths() {
return {
dataDir: getDataDir(),
settingsPath: getSettingsPath(),
archivesDir: getArchivesDir(),
logsDir: getLogsDir()
};
}
+8 -9
View File
@@ -1,10 +1,10 @@
{
"name": "claude-mem",
"version": "3.5.8",
"version": "3.9.9",
"description": "Memory compression system for Claude Code - persist context across sessions",
"keywords": [
"claude",
"claude-code",
"claude-agent-sdk",
"mcp",
"memory",
"compression",
@@ -36,22 +36,21 @@
"claude-mem": "./dist/claude-mem.min.js"
},
"dependencies": {
"@anthropic-ai/claude-code": "^1.0.88",
"@anthropic-ai/claude-agent-sdk": "^0.1.0",
"@clack/prompts": "^0.11.0",
"@modelcontextprotocol/sdk": "^0.5.0",
"boxen": "^8.0.1",
"chalk": "^5.6.0",
"chromadb": "^3.0.14",
"commander": "^14.0.0",
"glob": "^11.0.3",
"gradient-string": "^3.0.0",
"handlebars": "^4.7.8",
"oh-my-logo": "^0.3.2"
"handlebars": "^4.7.8"
},
"files": [
"dist",
"hooks",
"hook-templates",
"commands",
".mcp.json"
"src",
".mcp.json",
"CHANGELOG.md"
]
}
+270
View File
@@ -0,0 +1,270 @@
#!/usr/bin/env node
// <Block> 1.1 ====================================
// CLI Dependencies and Imports Setup
// Natural pattern: Import what you need before using it
import { Command } from 'commander';
import { PACKAGE_NAME, PACKAGE_VERSION, PACKAGE_DESCRIPTION } from '../shared/config.js';
// Import command handlers
import { install } from '../commands/install.js';
import { uninstall } from '../commands/uninstall.js';
import { status } from '../commands/status.js';
import { logs } from '../commands/logs.js';
import { loadContext } from '../commands/load-context.js';
import { trash } from '../commands/trash.js';
import { viewTrash } from '../commands/trash-view.js';
import { emptyTrash } from '../commands/trash-empty.js';
import { restore } from '../commands/restore.js';
import { changelog } from '../commands/changelog.js';
import { doctor } from '../commands/doctor.js';
import { storeMemory } from '../commands/store-memory.js';
import { storeOverview } from '../commands/store-overview.js';
import { updateSessionMetadata } from '../commands/update-session-metadata.js';
import { generateTitle } from '../commands/generate-title.js';
import {
executeChromaMCPTool,
loadChromaMCPTools,
generateCommandOptions
} from '../commands/chroma-mcp.js';
const program = new Command();
// </Block> =======================================
// <Block> 1.2 ====================================
// Program Configuration
// Natural pattern: Configure program metadata first
program
.name(PACKAGE_NAME)
.description(PACKAGE_DESCRIPTION)
.version(PACKAGE_VERSION);
// </Block> =======================================
// <Block> 1.3 ====================================
// Install Command Definition
// Natural pattern: Define command with its options and handler
// Install command
program
.command('install')
.description('Install Claude Code hooks for automatic compression')
.option('--user', 'Install for current user (default)')
.option('--project', 'Install for current project only')
.option('--local', 'Install to custom local directory')
.option('--path <path>', 'Custom installation path (with --local)')
.option('--timeout <ms>', 'Hook execution timeout in milliseconds', '180000')
.option('--skip-mcp', 'Skip Chroma MCP server installation')
.option('--force', 'Force installation even if already installed')
.action(install);
// </Block> =======================================
// <Block> 1.5 ====================================
// Uninstall Command Definition
// Natural pattern: Define command with its options and handler
// Uninstall command
program
.command('uninstall')
.description('Remove Claude Code hooks')
.option('--user', 'Remove from user settings (default)')
.option('--project', 'Remove from project settings')
.option('--all', 'Remove from both user and project settings')
.action(uninstall);
// </Block> =======================================
// <Block> 1.6 ====================================
// Status Command Definition
// Natural pattern: Define command with its handler
// Status command
program
.command('status')
.description('Check installation status of Claude Memory System')
.action(status);
// Doctor command
program
.command('doctor')
.description('Run environment and pipeline diagnostics for rolling memory')
.option('--json', 'Output JSON instead of text')
.action(async (options: any) => {
try {
await doctor(options);
} catch (error: any) {
console.error(`doctor failed: ${error.message || error}`);
process.exitCode = 1;
}
});
// </Block> =======================================
// <Block> 1.7 ====================================
// Logs Command Definition
// Natural pattern: Define command with its options and handler
// Logs command
program
.command('logs')
.description('View claude-mem operation logs')
.option('--debug', 'Show debug logs only')
.option('--error', 'Show error logs only')
.option('--tail [n]', 'Show last n lines', '50')
.option('--follow', 'Follow log output')
.action(logs);
// </Block> =======================================
// <Block> 1.8 ====================================
// Load-Context Command Definition
// Natural pattern: Define command with its options and handler
// Load-context command
program
.command('load-context')
.description('Load compressed memories for current session')
.option('--project <name>', 'Filter by project name')
.option('--count <n>', 'Number of memories to load', '10')
.option('--raw', 'Output raw JSON instead of formatted text')
.option('--format <type>', 'Output format: json, session-start, or default')
.action(loadContext);
// </Block> =======================================
// <Block> 1.9 ====================================
// Trash and Restore Commands Definition
// Natural pattern: Define commands for safe file operations
// Trash command with subcommands
const trashCmd = program
.command('trash')
.description('Manage trash bin for safe file deletion')
.argument('[files...]', 'Files to move to trash')
.option('-r, --recursive', 'Remove directories recursively')
.option('-R', 'Remove directories recursively (same as -r)')
.option('-f, --force', 'Suppress errors for nonexistent files')
.action(async (files: string[] | undefined, options: any) => {
// If no files provided, show help
if (!files || files.length === 0) {
trashCmd.outputHelp();
return;
}
// Map -R to recursive
if (options.R) options.recursive = true;
await trash(files, {
force: options.force,
recursive: options.recursive
});
});
// Trash view subcommand
trashCmd
.command('view')
.description('View contents of trash bin')
.action(viewTrash);
// Trash empty subcommand
trashCmd
.command('empty')
.description('Permanently delete all files in trash')
.option('-f, --force', 'Skip confirmation prompt')
.action(emptyTrash);
// Restore command
program
.command('restore')
.description('Restore files from trash interactively')
.action(restore);
// </Block> =======================================
// Store memory command (for SDK streaming)
program
.command('store-memory')
.description('Store a memory to all storage layers (used by SDK)')
.requiredOption('--id <id>', 'Memory ID')
.requiredOption('--project <project>', 'Project name')
.requiredOption('--session <session>', 'Session ID')
.requiredOption('--date <date>', 'Date (YYYY-MM-DD)')
.requiredOption('--title <title>', 'Memory title (3-8 words)')
.requiredOption('--subtitle <subtitle>', 'Memory subtitle (max 24 words)')
.requiredOption('--facts <json>', 'Atomic facts as JSON array')
.option('--concepts <json>', 'Concept tags as JSON array')
.option('--files <json>', 'Files touched as JSON array')
.action(storeMemory);
// Store overview command (for SDK streaming)
program
.command('store-overview')
.description('Store a session overview (used by SDK)')
.requiredOption('--project <project>', 'Project name')
.requiredOption('--session <session>', 'Session ID')
.requiredOption('--content <content>', 'Overview content')
.action(storeOverview);
// Update session metadata command (for SDK streaming)
program
.command('update-session-metadata')
.description('Update session title and subtitle (used by SDK)')
.requiredOption('--project <project>', 'Project name')
.requiredOption('--session <session>', 'Session ID')
.requiredOption('--title <title>', 'Session title (3-6 words)')
.option('--subtitle <subtitle>', 'Session subtitle (max 20 words)')
.action(updateSessionMetadata);
// Changelog command
program
.command('changelog')
.description('Generate CHANGELOG.md from claude-mem memories')
.option('--historical <n>', 'Number of versions to search (default: current version only)')
.option('--generate <version>', 'Generate changelog for a specific version')
.option('--start <time>', 'Start time for memory search (ISO format)')
.option('--end <time>', 'End time for memory search (ISO format)')
.option('--update', 'Update CHANGELOG.md from JSONL entries')
.option('--preview', 'Preview the generated changelog')
.option('-v, --verbose', 'Show detailed output')
.action(changelog);
// Generate title command
program
.command('generate-title <prompt>')
.description('Generate a session title and subtitle from a prompt')
.option('--json', 'Output as JSON')
.option('--oneline', 'Output as single line (title - subtitle)')
.option('--save', 'Save title and subtitle to session metadata')
.option('--project <name>', 'Project name (required with --save)')
.option('--session <id>', 'Session ID (required with --save)')
.action(generateTitle);
// </Block> =======================================
// <Block> 1.12 ===================================
// Dynamic Chroma MCP Commands
// Natural pattern: Register all Chroma MCP tools as CLI commands
try {
const chromaTools = loadChromaMCPTools();
for (const tool of chromaTools) {
const cmd = program
.command(tool.name)
.description(tool.description || `Execute ${tool.name} MCP tool`);
// Add options from tool schema
const options = generateCommandOptions(tool.inputSchema);
for (const opt of options) {
if (opt.required) {
cmd.requiredOption(opt.flag, opt.description);
} else {
cmd.option(opt.flag, opt.description);
}
}
// Set action handler
cmd.action(async (options: OptionValues) => {
await executeChromaMCPTool(tool.name, options);
});
}
} catch (error) {
console.warn('Warning: Could not load Chroma MCP tools:', error);
}
// </Block> =======================================
// <Block> 1.11 ===================================
// CLI Execution
// Natural pattern: After defining all commands, parse and execute
// Parse arguments and execute
program.parse();
// </Block> =======================================
+744
View File
@@ -0,0 +1,744 @@
import { OptionValues } from 'commander';
import { query } from '@anthropic-ai/claude-agent-sdk';
import fs from 'fs';
import path from 'path';
import { getClaudePath } from '../shared/settings.js';
import { execSync } from 'child_process';
interface ChangelogEntry {
version: string;
date: string;
type: 'Added' | 'Changed' | 'Fixed' | 'Removed' | 'Deprecated' | 'Security';
description: string;
timestamp: string;
generatedAt?: string; // When this changelog entry was created
}
interface MemorySearchResult {
version: string;
text: string;
metadata: any;
}
export async function changelog(options: OptionValues): Promise<void> {
try {
// Handle --update flag to regenerate CHANGELOG.md from JSONL
if (options.update) {
await updateChangelogFromJsonl(options);
return;
}
// Get current version and project name from package.json
const packageJsonPath = path.join(process.cwd(), 'package.json');
let currentVersion = 'unknown';
let projectName = 'unknown';
if (fs.existsSync(packageJsonPath)) {
try {
const packageData = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
currentVersion = packageData.version || 'unknown';
projectName = packageData.name || path.basename(process.cwd());
} catch (e) {
projectName = path.basename(process.cwd());
}
}
// Calculate versions to search for based on flags
const versionsToSearch: string[] = [];
let historicalCount = options.historical || 1; // Default to current version only
// Handle --generate flag for specific version
if (options.generate) {
versionsToSearch.push(options.generate);
historicalCount = 1; // Single version mode
console.log(`🎯 Generating changelog for specific version: ${options.generate}`);
} else if (currentVersion !== 'unknown') {
// Normal mode: use current version or historical versions
const parts = currentVersion.split('.');
if (parts.length === 3) {
let major = parseInt(parts[0]);
let minor = parseInt(parts[1]);
let patch = parseInt(parts[2]);
for (let i = 0; i < historicalCount; i++) {
versionsToSearch.push(`${major}.${minor}.${patch}`);
// Decrement version
if (patch === 0) {
if (minor === 0) {
// Can't go lower than x.0.0
break;
}
minor--;
patch = 9;
} else {
patch--;
}
}
}
}
if (versionsToSearch.length === 0) {
console.log('⚠️ Could not determine versions to search. Please check package.json');
process.exit(1);
}
// Check if current version already has a changelog entry
const projectChangelogDir = path.join(
process.env.HOME || process.env.USERPROFILE || '',
'.claude-mem',
'projects'
);
const changelogJsonlPath = path.join(projectChangelogDir, `${projectName}-changelog.jsonl`);
let hasCurrentVersion = false;
if (fs.existsSync(changelogJsonlPath)) {
const existingLines = fs.readFileSync(changelogJsonlPath, 'utf-8').split('\n').filter(l => l.trim());
for (const line of existingLines) {
try {
const entry = JSON.parse(line);
if (entry.version === currentVersion) {
hasCurrentVersion = true;
}
} catch (e) {
// Skip invalid lines
}
}
if (!options.historical && !options.generate && historicalCount === 1) {
if (hasCurrentVersion) {
console.log(`❌ Version ${currentVersion} already has changelog entries.`);
console.log('\n📝 Workflow:');
console.log(' 1. Make your code updates');
console.log(' 2. Build and test: bun run build');
console.log(' 3. Bump version: npm version patch');
console.log(' 4. Generate changelog: claude-mem changelog');
console.log(' 5. Commit and push\n');
console.log(`💡 Or use --historical 1 to regenerate this version's changelog`);
process.exit(1);
}
}
}
// Get npm publish times for all versions we need
let versionTimeRanges: Array<{version: string, startTime: string, endTime: string}> = [];
// Check if custom time range is provided
if (options.start && options.end) {
// Use custom time range for the specified version
const version = options.generate || currentVersion;
versionTimeRanges.push({
version,
startTime: options.start,
endTime: options.end
});
console.log(`📅 Using custom time range for ${version}:`);
console.log(` Start: ${new Date(options.start).toLocaleString()}`);
console.log(` End: ${new Date(options.end).toLocaleString()}`);
} else {
try {
const npmTimeData = execSync(`npm view ${projectName} time --json`, {
encoding: 'utf-8',
timeout: 5000
});
const publishTimes = JSON.parse(npmTimeData);
// For historical mode, we need one extra previous version to get proper time ranges
// E.g., for 3 versions, we need 4 timestamps to create 3 ranges
let extraPrevVersion = '';
if (historicalCount > 1) {
// Get the version before our oldest version in the search list
const oldestVersion = versionsToSearch[versionsToSearch.length - 1];
const parts = oldestVersion.split('.');
const major = parseInt(parts[0]);
const minor = parseInt(parts[1]);
const patch = parseInt(parts[2]);
if (patch > 0) {
extraPrevVersion = `${major}.${minor}.${patch - 1}`;
} else if (minor > 0) {
// Look for highest patch of previous minor
const prevMinorPrefix = `${major}.${minor - 1}.`;
const prevMinorVersions = Object.keys(publishTimes)
.filter(v => v.startsWith(prevMinorPrefix))
.sort((a, b) => {
const aPatch = parseInt(a.split('.')[2] || '0');
const bPatch = parseInt(b.split('.')[2] || '0');
return bPatch - aPatch;
});
if (prevMinorVersions.length > 0) {
extraPrevVersion = prevMinorVersions[0];
}
} else if (major > 0) {
// Look for highest version of previous major
const prevMajorPrefix = `${major - 1}.`;
const prevMajorVersions = Object.keys(publishTimes)
.filter(v => v.startsWith(prevMajorPrefix))
.sort((a, b) => {
const [, aMinor, aPatch] = a.split('.').map(Number);
const [, bMinor, bPatch] = b.split('.').map(Number);
if (aMinor !== bMinor) return bMinor - aMinor;
return bPatch - aPatch;
});
if (prevMajorVersions.length > 0) {
extraPrevVersion = prevMajorVersions[0];
}
}
if (options.verbose && extraPrevVersion && publishTimes[extraPrevVersion]) {
console.log(`📍 Using ${extraPrevVersion} as start boundary for time ranges`);
}
}
// Build time ranges for each version
for (let i = 0; i < versionsToSearch.length; i++) {
const version = versionsToSearch[i];
// Start time:
// - For the first (newest) version, use the publish time of the version before it
// - For middle versions, use the publish time of the next version in our list
// - For the last (oldest) version, use the extra previous version we found
let startTime = '2000-01-01T00:00:00Z'; // Default to old date
if (i === 0) {
// First (newest) version - find its immediate predecessor
const versionParts = version.split('.');
const major = parseInt(versionParts[0]);
const minor = parseInt(versionParts[1]);
const patch = parseInt(versionParts[2]);
let prevVersion = '';
if (patch > 0) {
prevVersion = `${major}.${minor}.${patch - 1}`;
} else if (minor > 0) {
// Look for highest patch of previous minor
const prevMinorPrefix = `${major}.${minor - 1}.`;
const prevMinorVersions = Object.keys(publishTimes)
.filter(v => v.startsWith(prevMinorPrefix))
.sort((a, b) => {
const aPatch = parseInt(a.split('.')[2] || '0');
const bPatch = parseInt(b.split('.')[2] || '0');
return bPatch - aPatch;
});
if (prevMinorVersions.length > 0) {
prevVersion = prevMinorVersions[0];
}
}
if (publishTimes[prevVersion]) {
startTime = publishTimes[prevVersion];
}
} else if (i < versionsToSearch.length - 1) {
// Middle versions - use the next version in our list
const prevVersionInList = versionsToSearch[i + 1];
if (publishTimes[prevVersionInList]) {
startTime = publishTimes[prevVersionInList];
}
} else {
// Last (oldest) version - use the extra previous version
if (extraPrevVersion && publishTimes[extraPrevVersion]) {
startTime = publishTimes[extraPrevVersion];
}
}
// End time is this version's publish time (or now for unreleased)
let endTime = publishTimes[version] || new Date().toISOString();
versionTimeRanges.push({ version, startTime, endTime });
if (options.verbose) {
console.log(`📅 Version ${version}: ${new Date(startTime).toLocaleString()} - ${new Date(endTime).toLocaleString()}`);
}
}
// Always log what we're doing for single version
if (historicalCount === 1) {
const latestRange = versionTimeRanges[0];
if (latestRange) {
console.log(`📦 Using npm time range for ${latestRange.version}: ${new Date(latestRange.startTime).toLocaleString()} - ${new Date(latestRange.endTime).toLocaleString()}`);
}
}
} catch (e) {
console.log('❌ Could not fetch npm publish times. Cannot proceed without time ranges.');
process.exit(1);
}
}
console.log(`🔍 Searching memories for versions: ${versionsToSearch.join(', ')}`);
console.log(`📦 Project: ${projectName}\n`);
// Phase 1: Search for version-related memories using MCP tools
// ALWAYS use time range search - no other method
const searchPrompt = versionTimeRanges.length > 0 ?
`You are helping generate a changelog by searching for memories within specific time ranges for multiple versions.
PROJECT: ${projectName}
VERSION TIME RANGES:
${versionTimeRanges.map(r => `- Version ${r.version}: ${new Date(r.startTime).toLocaleDateString()} to ${new Date(r.endTime).toLocaleDateString()}`).join('\n')}
YOUR TASK:
Use mcp__claude-mem__chroma_query_documents to search for memories for each version time range.
SEARCH STRATEGY:
${versionTimeRanges.map(r => {
const startDate = new Date(r.startTime);
const endDate = new Date(r.endTime);
// Generate all date prefixes between start and end
const datePrefixes: string[] = [];
const currentDate = new Date(startDate);
while (currentDate <= endDate) {
// Add day prefix like "2025-09-09"
const dayPrefix = currentDate.toISOString().split('T')[0];
datePrefixes.push(dayPrefix);
currentDate.setDate(currentDate.getDate() + 1);
}
return `
Version ${r.version} (${new Date(r.startTime).toLocaleDateString()} to ${new Date(r.endTime).toLocaleDateString()}):
1. Search for memories from these dates: ${datePrefixes.join(', ')}
2. Make multiple calls to mcp__claude-mem__chroma_query_documents:
- collection_name: "claude_memories"
- query_texts: Include the project name AND date in each query:
* "${projectName} ${datePrefixes[0]} feature"
* "${projectName} ${datePrefixes[0]} fix"
* "${projectName} ${datePrefixes[0]} change"
* "${projectName} ${datePrefixes[0]} improvement"
* "${projectName} ${datePrefixes[0]} refactor"
- n_results: 50
3. The date in the query text helps semantic search find memories from that day
4. Assign memories to this version if their timestamp falls within:
- Start: ${r.startTime}
- End: ${r.endTime}`;
}).join('\n')}
IMPORTANT:
- Always include project name and date in query_texts for best results
- Semantic search will naturally find memories near those dates
- Group returned memories by version based on their timestamp metadata
Return a JSON object with this structure:
{
"memories": [
{
"version": "version_number",
"text": "memory content",
"metadata": {metadata object with timestamp},
"relevance": "high/medium/low"
}
]
}
Group memories by the version they belong to based on timestamp.
Start searching now.` :
`ERROR: No time ranges available. This should never happen.`;
if (versionTimeRanges.length === 0) {
console.log('❌ No time ranges available. Cannot search memories.');
process.exit(1);
}
if (options.verbose) {
console.log('📝 Calling Claude to search memories...');
}
// Call Claude with MCP tools to search memories
const searchResponse = await query({
prompt: searchPrompt,
options: {
allowedTools: [
'mcp__claude-mem__chroma_query_documents',
'mcp__claude-mem__chroma_get_documents'
],
pathToClaudeCodeExecutable: getClaudePath()
}
});
// Extract memories from response
let memoriesJson = '';
if (searchResponse && typeof searchResponse === 'object' && Symbol.asyncIterator in searchResponse) {
for await (const message of searchResponse) {
if (message?.type === 'assistant' && message?.message?.content) {
const content = message.message.content;
if (typeof content === 'string') {
memoriesJson += content;
} else if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'text' && block.text) {
memoriesJson += block.text;
}
}
}
}
}
}
// Parse memories
let memories: MemorySearchResult[] = [];
try {
// Extract JSON from response (might be wrapped in markdown)
const jsonMatch = memoriesJson.match(/```json\n([\s\S]*?)\n```/) ||
memoriesJson.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[1] || jsonMatch[0]);
if (parsed.memories && Array.isArray(parsed.memories)) {
memories = parsed.memories;
}
}
} catch (e) {
console.error('⚠️ Could not parse memory search results:', e);
}
if (memories.length === 0) {
console.log('\n⚠️ No version-related memories found for this version.');
console.log(' This is normal for the first release or when no changes were tracked.');
console.log(' Creating a placeholder changelog entry...');
// Create a minimal placeholder entry
const placeholderEntry: ChangelogEntry = {
version: versionsToSearch[0], // Use the first (current) version
date: todayStr,
type: 'Changed',
description: 'Initial release or minor updates',
timestamp: new Date().toISOString(),
generatedAt: new Date().toISOString()
};
// Save the placeholder entry
if (!fs.existsSync(projectChangelogDir)) {
fs.mkdirSync(projectChangelogDir, { recursive: true });
}
const jsonlContent = JSON.stringify(placeholderEntry) + '\n';
fs.appendFileSync(changelogJsonlPath, jsonlContent);
console.log(`✅ Created placeholder changelog entry for v${versionsToSearch[0]}`);
// Generate the CHANGELOG.md with the placeholder
await updateChangelogFromJsonl(options);
return; // Exit successfully
}
console.log(`✅ Found ${memories.length} version-related memories\n`);
// Get system date for accuracy
const systemDate = execSync('date "+%Y-%m-%d %H:%M:%S %Z"').toString().trim();
const todayStr = systemDate.split(' ')[0]; // YYYY-MM-DD format
// Phase 2: Generate changelog entries from memories
const changelogPrompt = `Analyze these memories and generate changelog entries.
PROJECT: ${projectName}
DATE: ${todayStr}
MEMORIES BY VERSION:
${versionsToSearch.map(version => {
const versionMemories = memories.filter(m => m.version === version);
if (versionMemories.length === 0) return `### Version ${version}\nNo memories found.`;
return `### Version ${version} (${versionMemories.length} memories):
${versionMemories.map((m, i) => `${i + 1}. ${m.text}`).join('\n')}`;
}).join('\n\n')}
INSTRUCTIONS:
1. Extract concrete changes, fixes, and additions from the memories
2. Categorize each change as: Added, Changed, Fixed, Removed, Deprecated, or Security
3. Write clear, user-facing descriptions
4. Start each entry with an action verb
5. Focus on what matters to users, not internal implementation details
Return ONLY a JSON array with this structure:
[
{
"version": "3.6.1",
"type": "Added",
"description": "New feature description"
},
{
"version": "3.6.1",
"type": "Fixed",
"description": "Bug fix description"
}
]`;
console.log('🔄 Generating changelog entries...');
// Call Claude to generate changelog entries
const changelogResponse = await query({
prompt: changelogPrompt,
options: {
allowedTools: [],
pathToClaudeCodeExecutable: getClaudePath()
}
});
// Extract JSON from response
let entriesJson = '';
if (changelogResponse && typeof changelogResponse === 'object' && Symbol.asyncIterator in changelogResponse) {
for await (const message of changelogResponse) {
if (message?.type === 'assistant' && message?.message?.content) {
const content = message.message.content;
if (typeof content === 'string') {
entriesJson += content;
} else if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'text' && block.text) {
entriesJson += block.text;
}
}
}
}
}
}
// Parse changelog entries
let entries: ChangelogEntry[] = [];
try {
// Extract JSON (might be wrapped in markdown)
const jsonMatch = entriesJson.match(/```json\n([\s\S]*?)\n```/) ||
entriesJson.match(/\[[\s\S]*\]/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[1] || jsonMatch[0]);
if (Array.isArray(parsed)) {
const generatedAt = new Date().toISOString();
entries = parsed.map(e => ({
...e,
date: todayStr,
timestamp: e.timestamp || generatedAt, // Memory timestamp if available
generatedAt: generatedAt // When this changelog was generated
}));
}
}
} catch (e) {
console.error('⚠️ Could not parse changelog entries:', e);
}
if (entries.length === 0) {
console.log('⚠️ No changelog entries generated.');
process.exit(1);
}
// Ensure project changelog directory exists
if (!fs.existsSync(projectChangelogDir)) {
fs.mkdirSync(projectChangelogDir, { recursive: true });
}
// Save entries to project JSONL file
console.log(`\n💾 Saving ${entries.length} changelog entries to ${path.basename(changelogJsonlPath)}`);
// When using --historical or --generate, remove old entries for the versions being regenerated
if ((options.historical && historicalCount > 1) || options.generate) {
let existingEntries: ChangelogEntry[] = [];
if (fs.existsSync(changelogJsonlPath)) {
const lines = fs.readFileSync(changelogJsonlPath, 'utf-8').split('\n').filter(l => l.trim());
for (const line of lines) {
try {
const entry = JSON.parse(line);
// Keep entries that are NOT in the versions we're regenerating
if (!versionsToSearch.includes(entry.version)) {
existingEntries.push(entry);
}
} catch (e) {
// Skip invalid lines
}
}
}
// Rewrite the file with filtered entries plus new ones
const allEntries = [...existingEntries, ...entries];
const jsonlContent = allEntries.map(entry => JSON.stringify(entry)).join('\n') + '\n';
fs.writeFileSync(changelogJsonlPath, jsonlContent);
console.log(`🔄 Regenerated entries for versions: ${versionsToSearch.join(', ')}`);
} else {
// Append new entries to JSONL
const jsonlContent = entries.map(entry => JSON.stringify(entry)).join('\n') + '\n';
fs.appendFileSync(changelogJsonlPath, jsonlContent);
}
// Now generate markdown from all JSONL entries
console.log('\n📝 Generating CHANGELOG.md from entries...');
// Read all entries from JSONL
let allEntries: ChangelogEntry[] = [];
if (fs.existsSync(changelogJsonlPath)) {
const lines = fs.readFileSync(changelogJsonlPath, 'utf-8').split('\n').filter(l => l.trim());
for (const line of lines) {
try {
allEntries.push(JSON.parse(line));
} catch (e) {
// Skip invalid lines
}
}
}
// Group entries by version
const entriesByVersion = new Map<string, ChangelogEntry[]>();
for (const entry of allEntries) {
if (!entriesByVersion.has(entry.version)) {
entriesByVersion.set(entry.version, []);
}
entriesByVersion.get(entry.version)!.push(entry);
}
// Generate markdown
let markdown = '# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).\n\n';
// Sort versions in descending order
const sortedVersions = Array.from(entriesByVersion.keys()).sort((a, b) => {
const aParts = a.split('.').map(Number);
const bParts = b.split('.').map(Number);
for (let i = 0; i < 3; i++) {
if (aParts[i] !== bParts[i]) return bParts[i] - aParts[i];
}
return 0;
});
for (const version of sortedVersions) {
const versionEntries = entriesByVersion.get(version)!;
const date = versionEntries[0].date || todayStr;
markdown += `\n## [${version}] - ${date}\n\n`;
// Group by type
const types: Array<ChangelogEntry['type']> = ['Added', 'Changed', 'Fixed', 'Removed', 'Deprecated', 'Security'];
for (const type of types) {
const typeEntries = versionEntries.filter(e => e.type === type);
if (typeEntries.length > 0) {
markdown += `### ${type}\n`;
for (const entry of typeEntries) {
markdown += `- ${entry.description}\n`;
}
markdown += '\n';
}
}
}
// Write the CHANGELOG.md
const changelogPath = path.join(process.cwd(), 'CHANGELOG.md');
fs.writeFileSync(changelogPath, markdown);
console.log(`✅ Generated CHANGELOG.md with ${allEntries.length} total entries across ${entriesByVersion.size} versions!`);
if (options.preview) {
console.log('\n📄 Preview:\n');
console.log(markdown.split('\n').slice(0, 30).join('\n'));
if (markdown.split('\n').length > 30) {
console.log('\n... (truncated for preview)');
}
}
} catch (error) {
console.error('❌ Error generating changelog:', error instanceof Error ? error.message : error);
if (error instanceof Error && error.stack) {
console.error('Stack:', error.stack);
}
process.exit(1);
}
}
async function updateChangelogFromJsonl(options: OptionValues): Promise<void> {
try {
// Get project name from package.json
const packageJsonPath = path.join(process.cwd(), 'package.json');
let projectName = 'unknown';
if (fs.existsSync(packageJsonPath)) {
try {
const packageData = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
projectName = packageData.name || path.basename(process.cwd());
} catch (e) {
projectName = path.basename(process.cwd());
}
}
const projectChangelogDir = path.join(
process.env.HOME || process.env.USERPROFILE || '',
'.claude-mem',
'projects'
);
const changelogJsonlPath = path.join(projectChangelogDir, `${projectName}-changelog.jsonl`);
if (!fs.existsSync(changelogJsonlPath)) {
console.log('❌ No changelog entries found. Generate some first with: claude-mem changelog');
process.exit(1);
}
console.log('📝 Updating CHANGELOG.md from JSONL entries...');
// Read all entries from JSONL
let allEntries: ChangelogEntry[] = [];
const lines = fs.readFileSync(changelogJsonlPath, 'utf-8').split('\n').filter(l => l.trim());
for (const line of lines) {
try {
allEntries.push(JSON.parse(line));
} catch (e) {
// Skip invalid lines
}
}
if (allEntries.length === 0) {
console.log('❌ No valid entries found in JSONL file');
process.exit(1);
}
// Group entries by version
const entriesByVersion = new Map<string, ChangelogEntry[]>();
for (const entry of allEntries) {
if (!entriesByVersion.has(entry.version)) {
entriesByVersion.set(entry.version, []);
}
entriesByVersion.get(entry.version)!.push(entry);
}
// Generate markdown
let markdown = '# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).\n\n';
// Sort versions in descending order
const sortedVersions = Array.from(entriesByVersion.keys()).sort((a, b) => {
const aParts = a.split('.').map(Number);
const bParts = b.split('.').map(Number);
for (let i = 0; i < 3; i++) {
if (aParts[i] !== bParts[i]) return bParts[i] - aParts[i];
}
return 0;
});
for (const version of sortedVersions) {
const versionEntries = entriesByVersion.get(version)!;
const date = versionEntries[0].date;
markdown += `\n## [${version}] - ${date}\n\n`;
// Group by type
const types: Array<ChangelogEntry['type']> = ['Added', 'Changed', 'Fixed', 'Removed', 'Deprecated', 'Security'];
for (const type of types) {
const typeEntries = versionEntries.filter(e => e.type === type);
if (typeEntries.length > 0) {
markdown += `### ${type}\n`;
for (const entry of typeEntries) {
markdown += `- ${entry.description}\n`;
}
markdown += '\n';
}
}
}
// Write the CHANGELOG.md
const changelogPath = path.join(process.cwd(), 'CHANGELOG.md');
fs.writeFileSync(changelogPath, markdown);
console.log(`✅ Updated CHANGELOG.md with ${allEntries.length} entries across ${entriesByVersion.size} versions!`);
if (options.preview) {
console.log('\n📄 Preview:\n');
console.log(markdown.split('\n').slice(0, 30).join('\n'));
if (markdown.split('\n').length > 30) {
console.log('\n... (truncated for preview)');
}
}
} catch (error) {
console.error('❌ Error updating changelog:', error instanceof Error ? error.message : error);
process.exit(1);
}
}
+196
View File
@@ -0,0 +1,196 @@
import { OptionValues } from 'commander';
import ChromaMCPClient from '../../chroma-mcp-tools/chroma-mcp-client.js';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* Generic Chroma MCP tool executor
* Dynamically calls any Chroma MCP tool with provided arguments
*/
export async function executeChromaMCPTool(toolName: string, options: OptionValues): Promise<void> {
const client = new ChromaMCPClient();
try {
await client.connect();
// Convert commander options to tool arguments
const toolArgs = convertOptionsToArgs(toolName, options);
// Call the MCP tool
const result = await client.callTool(toolName, toolArgs);
// Parse and format the result nicely
const formatted = formatMCPResult(result);
console.log(formatted);
await client.disconnect();
process.exit(0);
} catch (error: any) {
console.error(JSON.stringify({
success: false,
error: error.message || 'Unknown error calling MCP tool',
tool: toolName
}, null, 2));
await client.disconnect();
process.exit(1);
}
}
/**
* Format MCP tool result for clean CLI output
*/
function formatMCPResult(result: any): string {
// If result has content array (MCP protocol format)
if (result?.content && Array.isArray(result.content)) {
const textContent = result.content
.filter((item: any) => item.type === 'text')
.map((item: any) => item.text)
.join('\n');
// Try to parse as JSON for prettier output
try {
const parsed = JSON.parse(textContent);
return JSON.stringify(parsed, null, 2);
} catch {
// Not JSON, return as-is
return textContent;
}
}
// If result is already an object, pretty print it
if (typeof result === 'object') {
return JSON.stringify(result, null, 2);
}
// Fallback to string
return String(result);
}
/**
* Convert CLI options to MCP tool arguments
* Handles type conversion and array parsing
*/
function convertOptionsToArgs(toolName: string, options: OptionValues): Record<string, any> {
const args: Record<string, any> = {};
for (const [key, value] of Object.entries(options)) {
// Skip commander internal properties
if (key.startsWith('_') || typeof value === 'function') {
continue;
}
// Try to parse JSON strings
if (typeof value === 'string') {
try {
args[key] = JSON.parse(value);
} catch {
args[key] = value;
}
} else {
args[key] = value;
}
}
return args;
}
/**
* Load Chroma MCP tool definitions from JSON
*/
export function loadChromaMCPTools(): Array<{
name: string;
description: string;
inputSchema: any;
}> {
// Try multiple path resolutions for dev vs production
const possiblePaths = [
path.join(__dirname, '../../chroma-mcp-tools/CHROMA_MCP_TOOLS.json'),
path.join(process.cwd(), 'chroma-mcp-tools/CHROMA_MCP_TOOLS.json'),
path.join(__dirname, '../chroma-mcp-tools/CHROMA_MCP_TOOLS.json')
];
for (const toolsPath of possiblePaths) {
if (fs.existsSync(toolsPath)) {
const toolsJson = fs.readFileSync(toolsPath, 'utf-8');
return JSON.parse(toolsJson);
}
}
throw new Error('Could not find CHROMA_MCP_TOOLS.json');
}
/**
* Generate CLI command options from MCP tool schema
*/
export function generateCommandOptions(schema: any): Array<{
flag: string;
description: string;
required: boolean;
type: string;
}> {
const options: Array<{
flag: string;
description: string;
required: boolean;
type: string;
}> = [];
if (!schema.properties) return options;
const required = schema.required || [];
for (const [propName, propSchema] of Object.entries(schema.properties)) {
const prop = propSchema as any;
const isRequired = required.includes(propName);
// Determine type
let type = 'string';
if (prop.type === 'integer' || prop.type === 'number') {
type = 'number';
} else if (prop.type === 'array') {
type = 'array';
} else if (prop.type === 'object') {
type = 'json';
} else if (prop.anyOf) {
// Handle nullable types
const nonNullType = prop.anyOf.find((t: any) => t.type !== 'null');
if (nonNullType?.type === 'integer' || nonNullType?.type === 'number') {
type = 'number';
} else if (nonNullType?.type === 'array') {
type = 'array';
} else if (nonNullType?.type === 'object') {
type = 'json';
}
}
// Build flag
const flag = isRequired
? `--${propName} <${type}>`
: `--${propName} [${type}]`;
// Build description
let description = prop.title || propName;
if (prop.default !== undefined) {
description += ` (default: ${JSON.stringify(prop.default)})`;
}
if (type === 'array') {
description += ' (JSON array)';
} else if (type === 'json') {
description += ' (JSON object)';
}
options.push({
flag,
description,
required: isRequired,
type
});
}
return options;
}
+100
View File
@@ -0,0 +1,100 @@
import { OptionValues } from 'commander';
import fs from 'fs';
import path from 'path';
import { PathDiscovery } from '../services/path-discovery.js';
import { createStores } from '../services/sqlite/index.js';
import { rollingLog } from '../shared/rolling-log.js';
type CheckStatus = 'pass' | 'fail' | 'warn';
interface CheckResult {
name: string;
status: CheckStatus;
details?: string;
}
function printCheck(result: CheckResult): void {
const icon =
result.status === 'pass' ? '✅' : result.status === 'warn' ? '⚠️ ' : '❌';
const message = result.details ? `${result.name}: ${result.details}` : result.name;
console.log(`${icon} ${message}`);
}
export async function doctor(options: OptionValues = {}): Promise<void> {
const discovery = PathDiscovery.getInstance();
const checks: CheckResult[] = [];
// Data directory
try {
const dataDir = discovery.getDataDirectory();
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
checks.push({ name: `Data directory created at ${dataDir}`, status: 'warn' });
} else {
const stats = fs.statSync(dataDir);
let writable = false;
try {
fs.accessSync(dataDir, fs.constants.W_OK);
writable = true;
} catch {}
checks.push({
name: `Data directory ${dataDir}`,
status: stats.isDirectory() && writable ? 'pass' : 'fail',
details: stats.isDirectory() && writable ? 'accessible' : 'not writable'
});
}
} catch (error: any) {
checks.push({
name: 'Data directory',
status: 'fail',
details: error?.message || String(error)
});
}
// SQLite connectivity
let stores; // reuse for queue check
try {
stores = await createStores();
const sessionCount = stores.sessions.count();
checks.push({
name: 'SQLite database',
status: 'pass',
details: `${sessionCount} session${sessionCount === 1 ? '' : 's'} present`
});
} catch (error: any) {
checks.push({
name: 'SQLite database',
status: 'fail',
details: error?.message || String(error)
});
}
// Chroma connectivity
try {
const chromaDir = discovery.getChromaDirectory();
const chromaExists = fs.existsSync(chromaDir);
checks.push({
name: 'Chroma vector store',
status: chromaExists ? 'pass' : 'warn',
details: chromaExists ? `data dir ${path.resolve(chromaDir)}` : 'Not yet initialized'
});
} catch (error: any) {
checks.push({
name: 'Chroma vector store',
status: 'warn',
details: error?.message || 'Unable to check Chroma directory'
});
}
if (options.json) {
console.log(JSON.stringify({ checks }, null, 2));
} else {
console.log('claude-mem doctor');
console.log('=================');
checks.forEach(printCheck);
}
rollingLog('info', 'doctor run completed', {
status: checks.map((c) => c.status)
});
}
+179
View File
@@ -0,0 +1,179 @@
import { OptionValues } from 'commander';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { getClaudePath } from '../shared/settings.js';
import path from 'path';
import fs from 'fs';
const SESSION_DIR = path.join(process.env.HOME || '', '.claude-mem', 'sessions');
/**
* Generate a session title and subtitle from a user prompt
* CLI command that uses Agent SDK (like changelog.ts)
*/
export async function generateTitle(prompt: string, options: OptionValues): Promise<void> {
if (!prompt || prompt.trim().length === 0) {
console.error(JSON.stringify({
success: false,
error: 'Prompt is required'
}));
process.exit(1);
}
const systemPrompt = `You are a title and subtitle generator for claude-mem session metadata.
Your job is to analyze a user's request and generate:
1. A concise title (3-8 words)
2. A one-sentence subtitle (max 20 words)
TITLE GUIDELINES:
- 3-8 words maximum
- Scannable and clear
- Captures the core action or topic
- Professional and informative
- Examples:
* "Dark Mode Implementation"
* "Authentication Bug Fix"
* "API Rate Limiting Setup"
* "React Component Refactoring"
SUBTITLE GUIDELINES:
- One sentence, max 20 words
- Descriptive and specific
- Focus on the outcome or benefit
- Use active voice when possible
- Examples:
* "Adding theme toggle and dark color scheme support to the application"
* "Resolving login timeout issue affecting user session persistence"
* "Implementing request throttling to prevent API quota exhaustion"
OUTPUT FORMAT:
You must output EXACTLY two lines:
Line 1: Title only (no prefix, no quotes)
Line 2: Subtitle only (no prefix, no quotes)
EXAMPLE:
User request: "Help me add dark mode to my app"
Output:
Dark Mode Implementation
Adding theme toggle and dark color scheme support to the application
USER REQUEST:
${prompt}
Now generate the title and subtitle (two lines exactly):`;
try {
const response = await query({
prompt: systemPrompt,
options: {
allowedTools: [],
pathToClaudeCodeExecutable: getClaudePath()
}
});
// Extract text from response (same pattern as changelog.ts)
let fullResponse = '';
if (response && typeof response === 'object' && Symbol.asyncIterator in response) {
for await (const message of response) {
if (message?.type === 'assistant' && message?.message?.content) {
const content = message.message.content;
if (typeof content === 'string') {
fullResponse += content;
} else if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'text' && block.text) {
fullResponse += block.text;
}
}
}
}
}
}
// Parse the response - expecting exactly 2 lines
const lines = fullResponse.trim().split('\n').filter(line => line.trim().length > 0);
if (lines.length < 2) {
console.error(JSON.stringify({
success: false,
error: 'Could not generate title and subtitle',
response: fullResponse
}));
process.exit(1);
}
const title = lines[0].trim();
const subtitle = lines[1].trim();
// Save to session metadata if --save flag is provided
if (options.save) {
if (!options.project || !options.session) {
console.error(JSON.stringify({
success: false,
error: '--project and --session are required when using --save'
}));
process.exit(1);
}
try {
const sessionFile = path.join(SESSION_DIR, `${options.project}_streaming.json`);
if (!fs.existsSync(sessionFile)) {
console.error(JSON.stringify({
success: false,
error: `Session file not found: ${sessionFile}`
}));
process.exit(1);
}
let sessionData: any = {};
try {
sessionData = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
} catch (e) {
console.error(JSON.stringify({
success: false,
error: 'Failed to parse session file'
}));
process.exit(1);
}
// Update metadata
sessionData.promptTitle = title;
sessionData.promptSubtitle = subtitle;
sessionData.updatedAt = new Date().toISOString();
// Write back to file
fs.writeFileSync(sessionFile, JSON.stringify(sessionData, null, 2));
} catch (error: any) {
console.error(JSON.stringify({
success: false,
error: `Failed to save metadata: ${error.message}`
}));
process.exit(1);
}
}
// Output format depends on options
if (options.json) {
console.log(JSON.stringify({
success: true,
title,
subtitle
}, null, 2));
} else if (options.oneline) {
console.log(`${title} - ${subtitle}`);
} else {
console.log(title);
console.log(subtitle);
}
} catch (error: any) {
console.error(JSON.stringify({
success: false,
error: error.message || 'Unknown error generating title'
}));
process.exit(1);
}
}
File diff suppressed because it is too large Load Diff
+461
View File
@@ -0,0 +1,461 @@
import { OptionValues } from 'commander';
import fs from 'fs';
import { join } from 'path';
import { PathDiscovery } from '../services/path-discovery.js';
import {
createCompletionMessage,
createContextualError,
createUserFriendlyError,
formatTimeAgo,
outputSessionStartContent
} from '../prompts/templates/context/ContextTemplates.js';
import { getStorageProvider, needsMigration } from '../shared/storage.js';
import { MemoryRow, OverviewRow } from '../services/sqlite/types.js';
import { createStores } from '../services/sqlite/index.js';
import { getRollingSettings } from '../shared/rolling-settings.js';
import { rollingLog } from '../shared/rolling-log.js';
interface TrashStatus {
folderCount: number;
fileCount: number;
totalSize: number;
isEmpty: boolean;
}
function formatDateHeader(date = new Date()): string {
return date.toLocaleString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short'
});
}
function wordWrap(text: string, maxWidth: number, prefix: string): string {
const words = text.split(' ');
const lines: string[] = [];
let currentLine = prefix;
const continuationPrefix = ' '.repeat(prefix.length);
for (const word of words) {
const needsSpace = currentLine !== prefix && currentLine !== continuationPrefix;
const testLine = currentLine + (needsSpace ? ' ' : '') + word;
if (testLine.length <= maxWidth) {
currentLine = testLine;
} else {
if (currentLine.trim()) {
lines.push(currentLine);
}
currentLine = continuationPrefix + word;
}
}
if (currentLine.trim()) {
lines.push(currentLine);
}
return lines.join('\n');
}
function buildProjectMatcher(projectName: string): (value?: string) => boolean {
const aliases = new Set<string>();
aliases.add(projectName);
aliases.add(projectName.replace(/-/g, '_'));
aliases.add(projectName.replace(/_/g, '-'));
return (value?: string) => !!value && aliases.has(value);
}
function formatSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function getTrashStatus(): TrashStatus {
const trashDir = PathDiscovery.getInstance().getTrashDirectory();
if (!fs.existsSync(trashDir)) {
return { folderCount: 0, fileCount: 0, totalSize: 0, isEmpty: true };
}
const items = fs.readdirSync(trashDir);
if (items.length === 0) {
return { folderCount: 0, fileCount: 0, totalSize: 0, isEmpty: true };
}
let folderCount = 0;
let fileCount = 0;
let totalSize = 0;
for (const item of items) {
const itemPath = join(trashDir, item);
const stats = fs.statSync(itemPath);
if (stats.isDirectory()) {
folderCount++;
} else {
fileCount++;
}
totalSize += stats.size;
}
return { folderCount, fileCount, totalSize, isEmpty: false };
}
async function renderRollingSessionStart(projectOverride?: string): Promise<void> {
const settings = getRollingSettings();
if (!settings.sessionStartEnabled) {
console.log('Rolling session-start output disabled in settings.');
rollingLog('info', 'session-start output skipped (disabled)', {
project: projectOverride
});
return;
}
const stores = await createStores();
const projectName = projectOverride || PathDiscovery.getCurrentProjectName();
// Get all overviews for this project (oldest to newest)
const allOverviews = stores.overviews.getAllForProject(projectName);
// Limit to last 10 overviews
const recentOverviews = allOverviews.slice(-10);
// If no data at all, show friendly message
if (recentOverviews.length === 0) {
console.log('===============================================================================');
console.log(`What's new | ${formatDateHeader()}`);
console.log('===============================================================================');
console.log('No previous sessions found for this project.');
console.log('Start working and claude-mem will automatically capture context for future sessions.');
console.log('===============================================================================');
const trashStatus = getTrashStatus();
if (!trashStatus.isEmpty) {
const formattedSize = formatSize(trashStatus.totalSize);
console.log(
`🗑️ Trash ${trashStatus.folderCount} folders | ${trashStatus.fileCount} files | ${formattedSize} use \`claude-mem restore\``
);
console.log('===============================================================================');
}
return;
}
// Output header
console.log('===============================================================================');
console.log(`What's new | ${formatDateHeader()}`);
console.log('===============================================================================');
// Output each overview with timestamp, memory names, and files touched (oldest to newest)
recentOverviews.forEach((overview) => {
const date = new Date(overview.created_at);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = date.getHours();
const minutes = String(date.getMinutes()).padStart(2, '0');
const ampm = hours >= 12 ? 'PM' : 'AM';
const displayHours = hours % 12 || 12;
console.log(`[${year}-${month}-${day} at ${displayHours}:${minutes} ${ampm}]`);
// Get memories for this session to show titles, subtitles, files, and keywords
const sessionMemories = stores.memories.getBySessionId(overview.session_id);
// Extract memory titles and subtitles
const memories = sessionMemories
.map(m => ({ title: m.title, subtitle: m.subtitle }))
.filter(m => m.title);
// Extract unique files touched across all memories
const allFilesTouched = new Set<string>();
const allKeywords = new Set<string>();
sessionMemories.forEach(m => {
if (m.files_touched) {
try {
const files = JSON.parse(m.files_touched);
if (Array.isArray(files)) {
files.forEach(f => allFilesTouched.add(f));
}
} catch (e) {
// Skip invalid JSON
}
}
if (m.keywords) {
// Keywords are comma-separated
m.keywords.split(',').forEach(k => allKeywords.add(k.trim()));
}
});
console.log('');
// Always show overview content
console.log(wordWrap(overview.content, 80, ''));
// Display files touched if any
if (allFilesTouched.size > 0) {
console.log('');
console.log(wordWrap(`- ${Array.from(allFilesTouched).join(', ')}`, 80, ''));
}
// Display keywords/tags if any
if (allKeywords.size > 0) {
console.log('');
console.log(wordWrap(`Tags: ${Array.from(allKeywords).join(', ')}`, 80, ''));
}
console.log('');
});
console.log('===============================================================================');
const trashStatus = getTrashStatus();
if (!trashStatus.isEmpty) {
const formattedSize = formatSize(trashStatus.totalSize);
console.log(
`🗑️ Trash ${trashStatus.folderCount} folders | ${trashStatus.fileCount} files | ${formattedSize} use \`claude-mem restore\``
);
console.log('===============================================================================');
}
}
export async function loadContext(options: OptionValues = {}): Promise<void> {
try {
// Check if migration is needed and warn the user
if (await needsMigration()) {
console.warn('⚠️ JSONL to SQLite migration recommended. Run: claude-mem migrate-index');
}
const storage = await getStorageProvider();
// If using JSONL fallback, use original implementation
if (storage.backend === 'jsonl') {
return await loadContextFromJSONL(options);
}
// SQLite implementation - fetch data using storage provider
let recentMemories: MemoryRow[] = [];
let recentOverviews: OverviewRow[] = [];
// Auto-detect current project for session-start format if no project specified
let projectToUse = options.project;
if (!projectToUse && options.format === 'session-start') {
projectToUse = PathDiscovery.getCurrentProjectName();
}
if (options.format === 'session-start') {
await renderRollingSessionStart(projectToUse);
return;
}
const overviewLimit = options.format === 'json' ? 5 : 3;
if (projectToUse) {
recentMemories = await storage.getRecentMemoriesForProject(projectToUse, 10);
recentOverviews = await storage.getRecentOverviewsForProject(projectToUse, overviewLimit);
} else {
recentMemories = await storage.getRecentMemories(10);
recentOverviews = await storage.getRecentOverviews(overviewLimit);
}
// Convert SQLite rows to JSONL format for compatibility with existing output functions
const memoriesAsJSON = recentMemories.map(row => ({
type: 'memory',
text: row.text,
document_id: row.document_id,
keywords: row.keywords,
session_id: row.session_id,
project: row.project,
timestamp: row.created_at,
archive: row.archive_basename
}));
const overviewsAsJSON = recentOverviews.map(row => ({
type: 'overview',
content: row.content,
session_id: row.session_id,
project: row.project,
timestamp: row.created_at
}));
// If no data found, show appropriate messages
if (memoriesAsJSON.length === 0 && overviewsAsJSON.length === 0) {
return;
}
if (options.format === 'json') {
// For JSON format, combine last 10 of each type
const recentObjects = [...memoriesAsJSON, ...overviewsAsJSON];
console.log(JSON.stringify(recentObjects));
} else {
// Default format - show last 10 memories and last 3 overviews
const totalCount = memoriesAsJSON.length + overviewsAsJSON.length;
console.log(createCompletionMessage('Context loading', totalCount, 'recent entries found'));
// Show memories first
memoriesAsJSON.forEach((obj) => {
console.log(`${obj.text} | ${obj.document_id} | ${obj.keywords}`);
});
// Then show overviews
overviewsAsJSON.forEach((obj) => {
console.log(`**Overview:** ${obj.content}`);
});
}
// Display trash status if not empty (except for JSON format to avoid breaking JSON parsing)
if (options.format !== 'json') {
const trashStatus = getTrashStatus();
if (!trashStatus.isEmpty) {
const formattedSize = formatSize(trashStatus.totalSize);
console.log(`🗑️ Trash ${trashStatus.folderCount} folders | ${trashStatus.fileCount} files | ${formattedSize} use \`claude-mem restore\``);
console.log('');
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (options.format === 'session-start') {
console.log(createContextualError('CONNECTION_FAILED', errorMessage));
} else {
console.log(createUserFriendlyError('Context loading', errorMessage, 'Check file permissions and try again'));
}
}
}
/**
* Original JSONL-based implementation for fallback compatibility
*/
async function loadContextFromJSONL(options: OptionValues = {}): Promise<void> {
const pathDiscovery = PathDiscovery.getInstance();
const indexPath = pathDiscovery.getIndexPath();
// Auto-detect current project for session-start format if no project specified
let projectToUse = options.project;
if (!projectToUse && options.format === 'session-start') {
projectToUse = PathDiscovery.getCurrentProjectName();
}
// Check if index file exists
if (!fs.existsSync(indexPath)) {
if (options.format === 'session-start') {
console.log(createContextualError('NO_MEMORIES', projectToUse || 'this project'));
}
return;
}
const content = fs.readFileSync(indexPath, 'utf-8');
const lines = content.trim().split('\n').filter(line => line.trim());
if (lines.length === 0) {
if (options.format === 'session-start') {
console.log(createContextualError('NO_MEMORIES', projectToUse || 'this project'));
}
return;
}
// Parse JSONL format - each line is a JSON object
const jsonObjects: any[] = [];
for (const line of lines) {
try {
// Skip lines that don't look like JSON (could be legacy format)
if (!line.trim().startsWith('{')) {
continue;
}
const obj = JSON.parse(line);
jsonObjects.push(obj);
} catch (e) {
// Skip malformed JSON lines
continue;
}
}
if (jsonObjects.length === 0) {
if (options.format === 'session-start') {
console.log(createContextualError('NO_MEMORIES', projectToUse || 'this project'));
}
return;
}
// Separate memories, overviews, and other types
const memories = jsonObjects.filter(obj => obj.type === 'memory');
const overviews = jsonObjects.filter(obj => obj.type === 'overview');
const sessions = jsonObjects.filter(obj => obj.type === 'session');
// Filter each type by project if specified
// Handle both hyphen and underscore formats since index has mixed entries
let filteredMemories = memories;
let filteredOverviews = overviews;
let filteredSessions = sessions;
if (projectToUse) {
const matchesProject = buildProjectMatcher(projectToUse);
filteredMemories = memories.filter(obj => matchesProject(obj.project));
filteredOverviews = overviews.filter(obj => matchesProject(obj.project));
filteredSessions = sessions.filter(obj => matchesProject(obj.project));
}
if (options.format === 'session-start') {
// Get last 10 memories and last 10 overviews for session-start
const recentMemories = filteredMemories.slice(-10);
const recentOverviews = filteredOverviews.slice(-10);
const recentSessions = filteredSessions.slice(-10);
// Combine them for the display
const recentObjects = [...recentSessions, ...recentMemories, ...recentOverviews];
// Find most recent timestamp for last session info
let lastSessionTime = 'recently';
const timestamps = recentObjects
.map(obj => {
// Get timestamp from JSON object
return obj.timestamp ? new Date(obj.timestamp) : null;
})
.filter(date => date !== null)
.sort((a, b) => b.getTime() - a.getTime());
if (timestamps.length > 0) {
lastSessionTime = formatTimeAgo(timestamps[0]);
}
// Use dual-stream output for session start formatting
outputSessionStartContent({
projectName: projectToUse || 'your project',
memoryCount: recentMemories.length,
lastSessionTime,
recentObjects
});
} else if (options.format === 'json') {
// For JSON format, combine last 10 of each type
const recentMemories = filteredMemories.slice(-10);
const recentOverviews = filteredOverviews.slice(-3);
const recentObjects = [...recentMemories, ...recentOverviews];
console.log(JSON.stringify(recentObjects));
} else {
// Default format - show last 10 memories and last 3 overviews
const recentMemories = filteredMemories.slice(-10);
const recentOverviews = filteredOverviews.slice(-3);
const totalCount = recentMemories.length + recentOverviews.length;
console.log(createCompletionMessage('Context loading', totalCount, 'recent entries found'));
// Show memories first
recentMemories.forEach((obj) => {
console.log(`${obj.text} | ${obj.document_id} | ${obj.keywords}`);
});
// Then show overviews
recentOverviews.forEach((obj) => {
console.log(`**Overview:** ${obj.content}`);
});
}
}
+84
View File
@@ -0,0 +1,84 @@
import { OptionValues } from 'commander';
import { readFileSync, readdirSync, statSync } from 'fs';
import { join } from 'path';
import { PathDiscovery } from '../services/path-discovery.js';
// <Block> 1.1 ====================================
async function showLog(logPath: string, logType: string, tail: number): Promise<void> {
// <Block> 1.2 ====================================
try {
const content = readFileSync(logPath, 'utf8');
const lines = content.split('\n').filter(line => line.trim());
const displayLines = lines.slice(-tail);
console.log(`📋 ${logType} Logs (last ${tail} lines):`);
console.log(` File: ${logPath}`);
console.log('');
// <Block> 1.3 ====================================
if (displayLines.length === 0) {
console.log(' No log entries found');
// </Block> =======================================
} else {
displayLines.forEach(line => {
console.log(` ${line}`);
});
}
// </Block> =======================================
console.log('');
// </Block> =======================================
} catch (error) {
// <Block> 1.4 ====================================
console.log(`❌ Could not read ${logType.toLowerCase()} log: ${logPath}`);
// </Block> =======================================
}
// </Block> =======================================
}
// <Block> 2.1 ====================================
export async function logs(options: OptionValues = {}): Promise<void> {
// <Block> 2.2 ====================================
const logsDir = PathDiscovery.getLogsDirectory();
const tail = parseInt(options.tail) || 20;
// </Block> =======================================
// Find most recent log file
try {
const files = readdirSync(logsDir);
const logFiles = files
.filter(f => f.startsWith('claude-mem-') && f.endsWith('.log'))
.map(f => ({
name: f,
path: join(logsDir, f),
mtime: statSync(join(logsDir, f)).mtime
}))
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
if (logFiles.length === 0) {
console.log('❌ No log files found in ~/.claude-mem/logs/');
return;
}
// Show most recent log
await showLog(logFiles[0].path, 'Most Recent', tail);
if (options.all && logFiles.length > 1) {
console.log(`📚 Found ${logFiles.length} total log files`);
}
} catch (error) {
console.log('❌ Could not read logs directory: ~/.claude-mem/logs/');
console.log(' Run a compression first to generate logs');
}
// <Block> 2.5 ====================================
if (options.follow) {
console.log('Following logs... (Press Ctrl+C to stop)');
// Basic follow implementation - would need more sophisticated watching in real usage
setInterval(() => {
// This would need proper file watching implementation
}, 1000);
}
// </Block> =======================================
// </Block> =======================================
}
+24
View File
@@ -0,0 +1,24 @@
import { readdirSync, renameSync } from 'fs';
import { join } from 'path';
import * as p from '@clack/prompts';
import { PathDiscovery } from '../services/path-discovery.js';
export async function restore(): Promise<void> {
const trashDir = PathDiscovery.getInstance().getTrashDirectory();
const files = readdirSync(trashDir);
if (files.length === 0) {
console.log('Trash is empty');
return;
}
const file = await p.select({
message: 'Select file to restore:',
options: files.map(f => ({ value: f, label: f }))
});
if (p.isCancel(file)) return;
renameSync(join(trashDir, file), join(process.cwd(), file));
console.log(`Restored ${file}`);
}
+213
View File
@@ -0,0 +1,213 @@
import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
import { join, resolve, dirname } from 'path';
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
import { PathDiscovery } from '../services/path-discovery.js';
import { DatabaseManager } from '../services/sqlite/Database.js';
import { SessionStore } from '../services/sqlite/SessionStore.js';
import chalk from 'chalk';
const __dirname = dirname(fileURLToPath(import.meta.url));
export async function status(): Promise<void> {
console.log('🔍 Claude Memory System Status Check');
console.log('=====================================\n');
console.log('📂 Runtime Hook Scripts (installed from hook-templates/):');
const pathDiscovery = PathDiscovery.getInstance();
const runtimeHooksDir = pathDiscovery.getHooksDirectory();
const sessionStartScript = join(runtimeHooksDir, 'session-start.js');
const stopScript = join(runtimeHooksDir, 'stop.js');
const userPromptScript = join(runtimeHooksDir, 'user-prompt-submit.js');
const postToolScript = join(runtimeHooksDir, 'post-tool-use.js');
const checkScript = (path: string, name: string) => {
if (existsSync(path)) {
console.log(`${name}: Found at ${path}`);
} else {
console.log(`${name}: Not found at ${path}`);
}
};
checkScript(sessionStartScript, 'session-start.js');
checkScript(stopScript, 'stop.js');
checkScript(userPromptScript, 'user-prompt-submit.js');
checkScript(postToolScript, 'post-tool-use.js');
console.log('');
console.log('⚙️ Settings Configuration:');
const checkSettings = (name: string, path: string) => {
if (!existsSync(path)) {
console.log(` ⏭️ ${name}: No settings file`);
return;
}
console.log(` 📋 ${name}: ${path}`);
try {
const settings = JSON.parse(readFileSync(path, 'utf8'));
const hasSessionStart = settings.hooks?.SessionStart?.some((matcher: any) =>
matcher.hooks?.some((hook: any) =>
hook.command?.includes('session-start.js') || hook.command?.includes('claude-mem')
)
);
const hasStop = settings.hooks?.Stop?.some((matcher: any) =>
matcher.hooks?.some((hook: any) =>
hook.command?.includes('stop.js') || hook.command?.includes('claude-mem')
)
);
const hasUserPrompt = settings.hooks?.UserPromptSubmit?.some((matcher: any) =>
matcher.hooks?.some((hook: any) =>
hook.command?.includes('user-prompt-submit.js') || hook.command?.includes('claude-mem')
)
);
const hasPostTool = settings.hooks?.PostToolUse?.some((matcher: any) =>
matcher.hooks?.some((hook: any) =>
hook.command?.includes('post-tool-use.js') || hook.command?.includes('claude-mem')
)
);
console.log(` SessionStart: ${hasSessionStart ? '✅' : '❌'}`);
console.log(` Stop: ${hasStop ? '✅' : '❌'}`);
console.log(` UserPromptSubmit: ${hasUserPrompt ? '✅' : '❌'}`);
console.log(` PostToolUse: ${hasPostTool ? '✅' : '❌'}`);
} catch (error: any) {
console.log(` ⚠️ Could not parse settings`);
}
};
checkSettings('Global', pathDiscovery.getClaudeSettingsPath());
checkSettings('Project', join(process.cwd(), '.claude', 'settings.json'));
console.log('');
console.log('📦 Compressed Transcripts:');
const claudeProjectsDir = join(pathDiscovery.getClaudeConfigDirectory(), 'projects');
if (existsSync(claudeProjectsDir)) {
try {
let compressedCount = 0;
let archiveCount = 0;
const searchDir = (dir: string, depth = 0) => {
if (depth > 3) return;
const files = readdirSync(dir);
for (const file of files) {
const fullPath = join(dir, file);
const stats = statSync(fullPath);
if (stats.isDirectory() && !file.startsWith('.')) {
searchDir(fullPath, depth + 1);
} else if (file.endsWith('.jsonl.compressed')) {
compressedCount++;
} else if (file.endsWith('.jsonl.archive')) {
archiveCount++;
}
}
};
searchDir(claudeProjectsDir);
console.log(` Compressed files: ${compressedCount}`);
console.log(` Archive files: ${archiveCount}`);
} catch (error) {
console.log(` ⚠️ Could not scan projects directory`);
}
} else {
console.log(` ️ No Claude projects directory found`);
}
console.log('');
console.log('🔧 Runtime Environment:');
const checkCommand = (cmd: string, name: string) => {
try {
const version = execSync(`${cmd} --version`, { encoding: 'utf8' }).trim();
console.log(`${name}: ${version}`);
} catch {
console.log(`${name}: Not found`);
}
};
checkCommand('node', 'Node.js');
checkCommand('bun', 'Bun');
console.log('');
console.log('🧠 Chroma Storage Status:');
console.log(' ✅ Storage backend: Chroma MCP');
console.log(` 📍 Data location: ${pathDiscovery.getChromaDirectory()}`);
console.log(' 🔍 Features: Vector search, semantic similarity, document storage');
console.log('');
console.log('🤖 Claude Agent SDK Sessions:');
try {
const dbManager = DatabaseManager.getInstance();
await dbManager.initialize();
const sessionStore = new SessionStore();
const sessions = sessionStore.getAll();
if (sessions.length === 0) {
console.log(chalk.gray(' No active sessions'));
} else {
const activeCount = sessions.filter(s => {
const daysSinceUse = (Date.now() - s.last_used_epoch) / (1000 * 60 * 60 * 24);
return daysSinceUse < 7;
}).length;
console.log(` 📊 Total sessions: ${sessions.length}`);
console.log(` ✅ Active (< 7 days): ${activeCount}`);
console.log(chalk.dim(` 💡 View details: claude-mem sessions list`));
}
} catch (error) {
console.log(chalk.gray(' ⚠️ Could not load session info'));
}
console.log('');
console.log('📊 Summary:');
const globalPath = pathDiscovery.getClaudeSettingsPath();
const projectPath = join(process.cwd(), '.claude', 'settings.json');
let isInstalled = false;
let installLocation = 'Not installed';
try {
if (existsSync(globalPath)) {
const settings = JSON.parse(readFileSync(globalPath, 'utf8'));
if (settings.hooks?.SessionStart || settings.hooks?.Stop || settings.hooks?.PostToolUse) {
isInstalled = true;
installLocation = 'Global';
}
}
if (existsSync(projectPath)) {
const settings = JSON.parse(readFileSync(projectPath, 'utf8'));
if (settings.hooks?.SessionStart || settings.hooks?.Stop || settings.hooks?.PostToolUse) {
isInstalled = true;
installLocation = installLocation === 'Global' ? 'Global + Project' : 'Project';
}
}
} catch {}
if (isInstalled) {
console.log(` ✅ Claude Memory System is installed (${installLocation})`);
console.log('');
console.log('💡 To test: Use /compact in Claude Code');
} else {
console.log(` ❌ Claude Memory System is not installed`);
console.log('');
console.log('💡 To install: claude-mem install');
}
}
+154
View File
@@ -0,0 +1,154 @@
import { OptionValues } from 'commander';
import { spawnSync } from 'child_process';
import { createStores } from '../services/sqlite/index.js';
/**
* Store a memory to all three storage layers
* Called by SDK via bash during streaming memory capture
*/
export async function storeMemory(options: OptionValues): Promise<void> {
const { id, project, session, date, title, subtitle, facts, concepts, files } = options;
// Validate required fields
if (!id || !project || !session || !date) {
console.error('Error: All fields required: --id, --project, --session, --date');
process.exit(1);
}
// Validate hierarchical fields (required for v2 format)
if (!title || !subtitle || !facts) {
console.error('Error: Hierarchical format required: --title, --subtitle, --facts');
process.exit(1);
}
try {
const stores = await createStores();
const timestamp = new Date().toISOString();
// Ensure session exists
const sessionExists = await stores.sessions.has(session);
if (!sessionExists) {
await stores.sessions.create({
session_id: session,
project,
created_at: timestamp,
source: 'save'
});
}
// Parse JSON arrays if provided as strings
let factsArray: string | undefined;
let conceptsArray: string | undefined;
let filesArray: string | undefined;
try {
factsArray = facts ? JSON.stringify(JSON.parse(facts)) : undefined;
} catch (e) {
factsArray = facts; // Store as-is if not valid JSON
}
try {
conceptsArray = concepts ? JSON.stringify(JSON.parse(concepts)) : undefined;
} catch (e) {
conceptsArray = concepts; // Store as-is if not valid JSON
}
try {
filesArray = files ? JSON.stringify(JSON.parse(files)) : undefined;
} catch (e) {
filesArray = files; // Store as-is if not valid JSON
}
// Layer 1: SQLite Memory Index
const memoryExists = stores.memories.hasDocumentId(id);
if (!memoryExists) {
stores.memories.create({
document_id: id,
text: '', // Deprecated: hierarchical fields replace narrative text
keywords: '',
session_id: session,
project,
created_at: timestamp,
origin: 'streaming-sdk',
// Hierarchical fields (v2)
title: title || undefined,
subtitle: subtitle || undefined,
facts: factsArray,
concepts: conceptsArray,
files_touched: filesArray
});
}
// Layer 2: ChromaDB - Store hierarchical memory
if (factsArray) {
const factsJson = JSON.parse(factsArray);
const conceptsJson = conceptsArray ? JSON.parse(conceptsArray) : [];
const filesJson = filesArray ? JSON.parse(filesArray) : [];
// Store each atomic fact as a separate ChromaDB document
factsJson.forEach((fact: string, idx: number) => {
spawnSync('claude-mem', [
'chroma_add_documents',
'--collection_name', 'claude_memories',
'--documents', JSON.stringify([fact]),
'--ids', JSON.stringify([`${id}_fact_${String(idx).padStart(3, '0')}`]),
'--metadatas', JSON.stringify([{
type: 'fact',
parent_id: id,
fact_index: idx,
title,
subtitle,
project,
session_id: session,
created_at: timestamp,
created_at_epoch: Date.parse(timestamp),
keywords: '',
concepts: JSON.stringify(conceptsJson),
files_touched: JSON.stringify(filesJson),
origin: 'streaming-sdk'
}])
]);
});
// Store full narrative with hierarchical metadata
spawnSync('claude-mem', [
'chroma_add_documents',
'--collection_name', 'claude_memories',
'--documents', JSON.stringify([`${title}\n${subtitle}\n\n${factsJson.join('\n')}`]),
'--ids', JSON.stringify([id]),
'--metadatas', JSON.stringify([{
type: 'narrative',
title,
subtitle,
facts_count: factsJson.length,
project,
session_id: session,
created_at: timestamp,
created_at_epoch: Date.parse(timestamp),
keywords: '',
concepts: JSON.stringify(conceptsJson),
files_touched: JSON.stringify(filesJson),
origin: 'streaming-sdk'
}])
]);
}
// Success output (SDK will see this)
console.log(JSON.stringify({
success: true,
memory_id: id,
project,
session,
date,
timestamp,
hierarchical: !!(title && subtitle && facts)
}));
} catch (error: any) {
console.error(JSON.stringify({
success: false,
error: error.message || 'Unknown error storing memory'
}));
process.exit(1);
}
}
+45
View File
@@ -0,0 +1,45 @@
import { OptionValues } from 'commander';
import { createStores } from '../services/sqlite/index.js';
/**
* Store a session overview
* Called by SDK via bash at session end
*/
export async function storeOverview(options: OptionValues): Promise<void> {
const { project, session, content } = options;
// Validate required fields
if (!project || !session || !content) {
console.error('Error: All fields required: --project, --session, --content');
process.exit(1);
}
try {
const stores = await createStores();
const timestamp = new Date().toISOString();
// Create one overview per session (rolling log architecture)
stores.overviews.upsert({
session_id: session,
content,
created_at: timestamp,
project,
origin: 'streaming-sdk'
});
// Success output (SDK will see this)
console.log(JSON.stringify({
success: true,
project,
session,
timestamp
}));
} catch (error: any) {
console.error(JSON.stringify({
success: false,
error: error.message || 'Unknown error storing overview'
}));
process.exit(1);
}
}
+66
View File
@@ -0,0 +1,66 @@
import { rmSync, readdirSync, existsSync, statSync } from 'fs';
import { join } from 'path';
import * as p from '@clack/prompts';
import { PathDiscovery } from '../services/path-discovery.js';
export async function emptyTrash(options: { force?: boolean } = {}): Promise<void> {
const trashDir = PathDiscovery.getInstance().getTrashDirectory();
// Check if trash directory exists
if (!existsSync(trashDir)) {
p.log.info('🗑️ Trash is already empty');
return;
}
try {
const files = readdirSync(trashDir);
if (files.length === 0) {
p.log.info('🗑️ Trash is already empty');
return;
}
// Count items
let folderCount = 0;
let fileCount = 0;
for (const file of files) {
const filePath = join(trashDir, file);
const stats = statSync(filePath);
if (stats.isDirectory()) {
folderCount++;
} else {
fileCount++;
}
}
// Confirm deletion unless --force flag is used
if (!options.force) {
const confirm = await p.confirm({
message: `Permanently delete ${folderCount} folders and ${fileCount} files from trash?`,
initialValue: false
});
if (p.isCancel(confirm) || !confirm) {
p.log.info('Cancelled - trash not emptied');
return;
}
}
// Delete all files in trash
const s = p.spinner();
s.start('Emptying trash...');
for (const file of files) {
const filePath = join(trashDir, file);
rmSync(filePath, { recursive: true, force: true });
}
s.stop(`🗑️ Trash emptied - permanently deleted ${folderCount} folders and ${fileCount} files`);
} catch (error) {
p.log.error('Failed to empty trash');
console.error(error);
process.exit(1);
}
}
+124
View File
@@ -0,0 +1,124 @@
import { readdirSync, statSync } from 'fs';
import { join, basename } from 'path';
import * as p from '@clack/prompts';
import { PathDiscovery } from '../services/path-discovery.js';
interface TrashItem {
originalName: string;
trashedName: string;
size: number;
trashedAt: Date;
isDirectory: boolean;
}
function parseTrashName(filename: string): { name: string; timestamp: number } {
const lastDotIndex = filename.lastIndexOf('.');
if (lastDotIndex === -1) return { name: filename, timestamp: 0 };
const timestamp = parseInt(filename.substring(lastDotIndex + 1));
if (isNaN(timestamp)) return { name: filename, timestamp: 0 };
return {
name: filename.substring(0, lastDotIndex),
timestamp
};
}
function formatSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function getDirectorySize(dirPath: string): number {
let size = 0;
const files = readdirSync(dirPath);
for (const file of files) {
const filePath = join(dirPath, file);
const stats = statSync(filePath);
if (stats.isDirectory()) {
size += getDirectorySize(filePath);
} else {
size += stats.size;
}
}
return size;
}
export async function viewTrash(): Promise<void> {
const trashDir = PathDiscovery.getInstance().getTrashDirectory();
try {
const files = readdirSync(trashDir);
if (files.length === 0) {
p.log.info('🗑️ Trash is empty');
return;
}
const items: TrashItem[] = files.map(file => {
const filePath = join(trashDir, file);
const stats = statSync(filePath);
const { name, timestamp } = parseTrashName(file);
const size = stats.isDirectory() ? getDirectorySize(filePath) : stats.size;
return {
originalName: name,
trashedName: file,
size,
trashedAt: new Date(timestamp),
isDirectory: stats.isDirectory()
};
});
// Sort by date, newest first
items.sort((a, b) => b.trashedAt.getTime() - a.trashedAt.getTime());
// Display header
console.log('\n🗑️ Trash Contents\n');
console.log('─'.repeat(80));
// Display items
let totalSize = 0;
let folderCount = 0;
let fileCount = 0;
for (const item of items) {
totalSize += item.size;
if (item.isDirectory) {
folderCount++;
} else {
fileCount++;
}
const type = item.isDirectory ? '📁' : '📄';
const date = item.trashedAt.toLocaleString();
const size = formatSize(item.size);
console.log(`${type} ${item.originalName}`);
console.log(` Size: ${size} | Trashed: ${date}`);
console.log(` ID: ${item.trashedName}`);
console.log();
}
// Display summary
console.log('─'.repeat(80));
console.log(`Total: ${folderCount} folders, ${fileCount} files (${formatSize(totalSize)})`);
console.log('\nTo restore files: claude-mem restore');
console.log('To empty trash: claude-mem trash empty');
} catch (error) {
if ((error as any).code === 'ENOENT') {
p.log.info('🗑️ Trash is empty');
} else {
p.log.error('Failed to read trash directory');
console.error(error);
}
}
}
+60
View File
@@ -0,0 +1,60 @@
import { renameSync, existsSync, mkdirSync, statSync } from 'fs';
import { join, basename } from 'path';
import { glob } from 'glob';
import { PathDiscovery } from '../services/path-discovery.js';
interface TrashOptions {
force?: boolean;
recursive?: boolean;
}
export async function trash(filePaths: string | string[], options: TrashOptions = {}): Promise<void> {
const trashDir = PathDiscovery.getInstance().getTrashDirectory();
if (!existsSync(trashDir)) mkdirSync(trashDir, { recursive: true });
// Handle single string or array of paths
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
for (const filePath of paths) {
// Handle glob patterns
const expandedPaths = await glob(filePath);
const actualPaths = expandedPaths.length > 0 ? expandedPaths : [filePath];
for (const actualPath of actualPaths) {
try {
// Check if file exists
if (!existsSync(actualPath)) {
if (!options.force) {
console.error(`trash: ${actualPath}: No such file or directory`);
continue;
}
// With -f, silently skip missing files
continue;
}
// Check if it's a directory and we need recursive
const stats = statSync(actualPath);
if (stats.isDirectory() && !options.recursive) {
if (!options.force) {
console.error(`trash: ${actualPath}: is a directory`);
continue;
}
}
// Generate unique destination name to avoid conflicts
const fileName = basename(actualPath);
const timestamp = Date.now();
const destination = join(trashDir, `${fileName}.${timestamp}`);
renameSync(actualPath, destination);
console.log(`Moved ${fileName} to trash`);
} catch (error) {
if (!options.force) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`trash: ${actualPath}: ${errorMessage}`);
}
}
}
}
}
+198
View File
@@ -0,0 +1,198 @@
import { OptionValues } from 'commander';
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { PathDiscovery } from '../services/path-discovery.js';
async function removeSmartTrashAlias(): Promise<boolean> {
const homeDir = homedir();
const shellConfigs = [
join(homeDir, '.bashrc'),
join(homeDir, '.zshrc'),
join(homeDir, '.bash_profile')
];
const aliasLine = 'alias rm="claude-mem trash"';
// Handle both variations of the comment line
const commentPatterns = [
'# claude-mem smart trash alias',
'# claude-mem trash bin alias'
];
let removedFromAny = false;
for (const configPath of shellConfigs) {
if (!existsSync(configPath)) continue;
let content = readFileSync(configPath, 'utf8');
// Check if alias exists
if (!content.includes(aliasLine)) {
continue; // Not configured in this file
}
// Remove the alias and its comment
const lines = content.split('\n');
const filteredLines = lines.filter((line, index) => {
// Skip the alias line
if (line.trim() === aliasLine) return false;
// Skip any claude-mem comment line if it's right before the alias
for (const commentPattern of commentPatterns) {
if (line.trim() === commentPattern &&
index + 1 < lines.length &&
lines[index + 1].trim() === aliasLine) {
return false;
}
}
return true;
});
const newContent = filteredLines.join('\n');
// Only write if content actually changed
if (newContent !== content) {
// Create backup
const backupPath = configPath + '.backup.' + Date.now();
writeFileSync(backupPath, content);
// Write updated content
writeFileSync(configPath, newContent);
console.log(`✅ Removed Smart Trash alias from ${configPath.replace(homeDir, '~')}`);
removedFromAny = true;
}
}
return removedFromAny;
}
export async function uninstall(options: OptionValues = {}): Promise<void> {
console.log('🔄 Uninstalling Claude Memory System hooks...');
const locations = [];
if (options.all) {
locations.push({
name: 'User',
path: PathDiscovery.getInstance().getClaudeSettingsPath()
});
locations.push({
name: 'Project',
path: join(process.cwd(), '.claude', 'settings.json')
});
} else {
const isProject = options.project;
const pathDiscovery = PathDiscovery.getInstance();
locations.push({
name: isProject ? 'Project' : 'User',
path: isProject ? join(process.cwd(), '.claude', 'settings.json') : pathDiscovery.getClaudeSettingsPath()
});
}
const pathDiscovery = PathDiscovery.getInstance();
const runtimeHooksDir = pathDiscovery.getHooksDirectory();
const preCompactScript = join(runtimeHooksDir, 'pre-compact.js');
const sessionStartScript = join(runtimeHooksDir, 'session-start.js');
const sessionEndScript = join(runtimeHooksDir, 'session-end.js');
let removedCount = 0;
for (const location of locations) {
if (!existsSync(location.path)) {
console.log(`⏭️ No settings found at ${location.name} location`);
continue;
}
const content = readFileSync(location.path, 'utf8');
const settings = JSON.parse(content);
if (!settings.hooks) {
console.log(`⏭️ No hooks configured in ${location.name} settings`);
continue;
}
let modified = false;
if (settings.hooks.PreCompact) {
const filteredPreCompact = settings.hooks.PreCompact.filter((matcher: any) =>
!matcher.hooks?.some((hook: any) =>
hook.command === preCompactScript ||
hook.command?.includes('pre-compact.js') ||
hook.command?.includes('claude-mem')
)
);
if (filteredPreCompact.length !== settings.hooks.PreCompact.length) {
settings.hooks.PreCompact = filteredPreCompact.length ? filteredPreCompact : undefined;
modified = true;
console.log(`✅ Removed PreCompact hook from ${location.name} settings`);
}
}
if (settings.hooks.SessionStart) {
const filteredSessionStart = settings.hooks.SessionStart.filter((matcher: any) =>
!matcher.hooks?.some((hook: any) =>
hook.command === sessionStartScript ||
hook.command?.includes('session-start.js') ||
hook.command?.includes('claude-mem')
)
);
if (filteredSessionStart.length !== settings.hooks.SessionStart.length) {
settings.hooks.SessionStart = filteredSessionStart.length ? filteredSessionStart : undefined;
modified = true;
console.log(`✅ Removed SessionStart hook from ${location.name} settings`);
}
}
if (settings.hooks.SessionEnd) {
const filteredSessionEnd = settings.hooks.SessionEnd.filter((matcher: any) =>
!matcher.hooks?.some((hook: any) =>
hook.command === sessionEndScript ||
hook.command?.includes('session-end.js') ||
hook.command?.includes('claude-mem')
)
);
if (filteredSessionEnd.length !== settings.hooks.SessionEnd.length) {
settings.hooks.SessionEnd = filteredSessionEnd.length ? filteredSessionEnd : undefined;
modified = true;
console.log(`✅ Removed SessionEnd hook from ${location.name} settings`);
}
}
if (settings.hooks.PreCompact === undefined) delete settings.hooks.PreCompact;
if (settings.hooks.SessionStart === undefined) delete settings.hooks.SessionStart;
if (settings.hooks.SessionEnd === undefined) delete settings.hooks.SessionEnd;
if (!Object.keys(settings.hooks).length) delete settings.hooks;
if (modified) {
const backupPath = location.path + '.backup.' + Date.now();
writeFileSync(backupPath, content);
console.log(`📋 Created backup: ${backupPath}`);
writeFileSync(location.path, JSON.stringify(settings, null, 2));
removedCount++;
console.log(`✅ Updated ${location.name} settings: ${location.path}`);
} else {
console.log(`️ No Claude Memory System hooks found in ${location.name} settings`);
}
}
// Remove Smart Trash alias from shell configs
const removedAlias = await removeSmartTrashAlias();
console.log('');
if (removedCount > 0 || removedAlias) {
console.log('✨ Uninstallation complete!');
if (removedCount > 0) {
console.log('The Claude Memory System hooks have been removed from your settings.');
}
if (removedAlias) {
console.log('The Smart Trash alias has been removed from your shell configuration.');
console.log('⚠️ Restart your terminal for the alias removal to take effect.');
}
console.log('');
console.log('Note: Your compressed transcripts and archives are preserved.');
console.log('To reinstall: claude-mem install');
} else {
console.log('️ No Claude Memory System hooks or aliases were found to remove.');
}
}
+80
View File
@@ -0,0 +1,80 @@
import { OptionValues } from 'commander';
import path from 'path';
import fs from 'fs';
const SESSION_DIR = path.join(process.env.HOME || '', '.claude-mem', 'sessions');
/**
* Update session metadata (title/subtitle) in the streaming session JSON file
* Called by SDK when generating session title at the start
*/
export async function updateSessionMetadata(options: OptionValues): Promise<void> {
const { project, session, title, subtitle } = options;
// Validate required fields
if (!project || !session) {
console.error(JSON.stringify({
success: false,
error: 'Missing required fields: --project, --session'
}));
process.exit(1);
}
if (!title) {
console.error(JSON.stringify({
success: false,
error: 'Missing required field: --title'
}));
process.exit(1);
}
try {
// Load existing session file
const sessionFile = path.join(SESSION_DIR, `${project}_streaming.json`);
if (!fs.existsSync(sessionFile)) {
console.error(JSON.stringify({
success: false,
error: `Session file not found: ${sessionFile}`
}));
process.exit(1);
}
let sessionData: any = {};
try {
sessionData = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
} catch (e) {
console.error(JSON.stringify({
success: false,
error: 'Failed to parse session file'
}));
process.exit(1);
}
// Update metadata
sessionData.promptTitle = title;
if (subtitle) {
sessionData.promptSubtitle = subtitle;
}
sessionData.updatedAt = new Date().toISOString();
// Write back to file
fs.writeFileSync(sessionFile, JSON.stringify(sessionData, null, 2));
// Output success
console.log(JSON.stringify({
success: true,
title,
subtitle: subtitle || null,
project,
session
}));
} catch (error: any) {
console.error(JSON.stringify({
success: false,
error: error.message || 'Unknown error updating session metadata'
}));
process.exit(1);
}
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Claude Memory System - Core Constants
*
* This file contains debug logging templates used throughout the application.
*/
// =============================================================================
// DEBUG AND LOGGING TEMPLATES
// =============================================================================
/**
* Debug logging message templates
*/
export const DEBUG_MESSAGES = {
COMPRESSION_STARTED: '🚀 COMPRESSION STARTED',
TRANSCRIPT_PATH: (path: string) => `📁 Transcript Path: ${path}`,
SESSION_ID: (id: string) => `🔍 Session ID: ${id}`,
PROJECT_NAME: (name: string) => `📝 PROJECT NAME: ${name}`,
CLAUDE_SDK_CALL: '🤖 Calling Claude SDK to analyze and populate memory database...',
TRANSCRIPT_STATS: (size: number, count: number) =>
`📊 Transcript size: ${size} characters, ${count} messages`,
COMPRESSION_COMPLETE: (count: number) => `✅ COMPRESSION COMPLETE\n Total summaries extracted: ${count}`,
CLAUDE_PATH_FOUND: (path: string) => `🎯 Found Claude Code at: ${path}`,
MCP_CONFIG_USED: (path: string) => `📋 Using MCP config: ${path}`
} as const;
+64
View File
@@ -0,0 +1,64 @@
/**
* Time utilities for formatting relative timestamps
*/
export function formatRelativeTime(timestamp: string | Date): string {
try {
const date = timestamp instanceof Date ? timestamp : new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
const diffWeeks = Math.floor(diffDays / 7);
const diffMonths = Math.floor(diffDays / 30);
if (diffSeconds < 60) {
return 'Just now';
} else if (diffMinutes < 60) {
return diffMinutes === 1 ? '1 minute ago' : `${diffMinutes} minutes ago`;
} else if (diffHours < 24) {
return diffHours === 1 ? '1 hour ago' : `${diffHours} hours ago`;
} else if (diffDays === 1) {
return 'Yesterday';
} else if (diffDays < 7) {
return `${diffDays} days ago`;
} else if (diffWeeks === 1) {
return '1 week ago';
} else if (diffWeeks < 4) {
return `${diffWeeks} weeks ago`;
} else if (diffMonths === 1) {
return '1 month ago';
} else if (diffMonths < 12) {
return `${diffMonths} months ago`;
} else {
const diffYears = Math.floor(diffMonths / 12);
return diffYears === 1 ? '1 year ago' : `${diffYears} years ago`;
}
} catch (error) {
// Return a fallback for invalid timestamps
return 'Recently';
}
}
export function parseTimestamp(entry: any): Date | null {
// Try multiple timestamp fields that might exist
const possibleFields = ['timestamp', 'created_at', 'date', 'time'];
for (const field of possibleFields) {
if (entry[field]) {
try {
const date = new Date(entry[field]);
if (!isNaN(date.getTime())) {
return date;
}
} catch {
continue;
}
}
}
// If no valid timestamp found, return null
return null;
}
+224
View File
@@ -0,0 +1,224 @@
# Hook Prompts System
This directory contains the centralized prompt configuration for all streaming hooks.
## Quick Edit Guide
**Want to change hook prompts?** Edit this file:
```
hook-prompts.config.ts
```
Then rebuild and reinstall:
```bash
bun run build
bun run dev:install
```
## Files in This Directory
### hook-prompts.config.ts
**EDIT THIS FILE** to change prompt content.
Contains:
- `SYSTEM_PROMPT` - Initial instructions for SDK (190 lines)
- `TOOL_MESSAGE` - Format for tool responses (10 lines)
- `END_MESSAGE` - Session completion request (10 lines)
- `HOOK_CONFIG` - Shared settings (truncation limits, SDK options)
Uses `{{variableName}}` template syntax.
### hook-prompt-renderer.ts
**DON'T EDIT** unless adding new variables or changing rendering logic.
Contains:
- `renderSystemPrompt()` - Processes system prompt template
- `renderToolMessage()` - Processes tool message template
- `renderEndMessage()` - Processes end message template
- Template substitution and auto-truncation logic
### templates/context/ContextTemplates.ts
Session start message formatting (separate from hook prompts).
## Template Variables Reference
### SYSTEM_PROMPT Variables
```typescript
{
project: string; // Project name (e.g., "claude-mem-source")
sessionId: string; // Claude Code session ID
date: string; // YYYY-MM-DD format
userPrompt: string; // Auto-truncated to 200 chars
}
```
### TOOL_MESSAGE Variables
```typescript
{
toolName: string; // Tool name (e.g., "Read", "Bash")
toolResponse: string; // Auto-truncated to 20000 chars
userPrompt: string; // Auto-truncated to 200 chars
timestamp: string; // Full ISO timestamp
timeFormatted: string; // HH:MM:SS format (auto-generated)
}
```
### END_MESSAGE Variables
```typescript
{
project: string; // Project name
sessionId: string; // Claude Code session ID
}
```
## Usage in Hooks
### user-prompt-submit-streaming.js
```javascript
import { renderSystemPrompt, HOOK_CONFIG } from '../src/prompts/hook-prompt-renderer.js';
const prompt = renderSystemPrompt({
project,
sessionId: session_id,
date,
userPrompt: prompt || ''
});
query({
prompt,
options: {
model: HOOK_CONFIG.sdk.model,
allowedTools: HOOK_CONFIG.sdk.allowedTools,
maxTokens: HOOK_CONFIG.sdk.maxTokensSystem
}
});
```
### post-tool-use-streaming.js
```javascript
import { renderToolMessage, HOOK_CONFIG } from '../src/prompts/hook-prompt-renderer.js';
const message = renderToolMessage({
toolName: tool_name,
toolResponse: toolResponseStr,
userPrompt: prompt || '',
timestamp: timestamp || new Date().toISOString()
});
query({
prompt: message,
options: {
model: HOOK_CONFIG.sdk.model,
maxTokens: HOOK_CONFIG.sdk.maxTokensTool
}
});
```
### stop-streaming.js
```javascript
import { renderEndMessage, HOOK_CONFIG } from '../src/prompts/hook-prompt-renderer.js';
const message = renderEndMessage({
project,
sessionId: claudeSessionId
});
query({
prompt: message,
options: {
model: HOOK_CONFIG.sdk.model,
maxTokens: HOOK_CONFIG.sdk.maxTokensEnd
}
});
```
## Configuration Options
Edit `HOOK_CONFIG` in `hook-prompts.config.ts`:
```typescript
export const HOOK_CONFIG = {
// Truncation limits for template variables
maxUserPromptLength: 200, // Increase to show more context
maxToolResponseLength: 20000, // Increase for larger outputs
// SDK configuration
sdk: {
model: 'claude-sonnet-4-5', // Change model version
allowedTools: ['Bash'], // Add more tools if needed
maxTokensSystem: 8192, // Token limit for system prompt
maxTokensTool: 8192, // Token limit for tool messages
maxTokensEnd: 2048, // Token limit for end message
},
};
```
## Example: Editing a Prompt
### Before
```typescript
export const TOOL_MESSAGE = `# Tool Response {{timeFormatted}}
Tool: {{toolName}}
User Context: "{{userPrompt}}"
\`\`\`
{{toolResponse}}
\`\`\`
Analyze and store if meaningful.`;
```
### After
```typescript
export const TOOL_MESSAGE = `# Analysis Request {{timeFormatted}}
Executed: {{toolName}}
Context: "{{userPrompt}}"
Priority: High
Output:
\`\`\`
{{toolResponse}}
\`\`\`
IMPORTANT: Only store if this contains:
- New code patterns or logic
- Architecture decisions
- Error messages with solutions
- Configuration changes
Skip trivial operations.`;
```
### Apply Changes
```bash
bun run build && bun run dev:install
```
## Benefits
### DRY Compliance
- **Before**: 3 files with 188 lines of hardcoded prompts
- **After**: 1 config file with all prompts centralized
### Maintainability
- Change prompts without touching hook implementation
- Type-safe template variables
- Consistent formatting across all hooks
- Version-controlled prompt history
### Flexibility
- Easy A/B testing of different instructions
- Simple to adjust truncation limits
- Quick model/token configuration changes
- Template variables prevent copy-paste errors
## Full Documentation
See `/Users/alexnewman/Scripts/claude-mem-source/docs/HOOK_PROMPTS.md` for:
- Detailed editing guide
- Troubleshooting common issues
- Adding new template variables
- Advanced customization
- Migration notes
+159
View File
@@ -0,0 +1,159 @@
/**
* Hook Prompt Renderer
*
* Simple template rendering for hook prompts.
* Handles variable substitution and auto-truncation.
*/
import {
PROMPTS,
HOOK_CONFIG,
type SystemPromptVariables,
type ToolMessageVariables,
type EndMessageVariables,
} from './hook-prompts.config.js';
// =============================================================================
// TEMPLATE RENDERING
// =============================================================================
/**
* Simple template variable substitution
* Replaces {{variableName}} with actual values
*/
function substituteVariables(
template: string,
variables: Record<string, string>
): string {
let result = template;
for (const [key, value] of Object.entries(variables)) {
const placeholder = `{{${key}}}`;
// Replace all occurrences of this placeholder
result = result.split(placeholder).join(value);
}
return result;
}
/**
* Truncate text with ellipsis if it exceeds maxLength
*/
function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength) + (text.length > maxLength ? '...' : '');
}
/**
* Format timestamp for tool message header
* Extracts HH:MM:SS from ISO timestamp
*/
function formatTime(timestamp: string): string {
const timePart = timestamp.split('T')[1];
if (!timePart) return '';
return timePart.slice(0, 8); // HH:MM:SS
}
// =============================================================================
// PUBLIC RENDERING FUNCTIONS
// =============================================================================
/**
* Render system prompt for SDK session initialization
*/
export function renderSystemPrompt(
variables: SystemPromptVariables
): string {
// Auto-truncate userPrompt
const userPromptTruncated = truncate(
variables.userPrompt,
HOOK_CONFIG.maxUserPromptLength
);
return substituteVariables(PROMPTS.system, {
project: variables.project,
sessionId: variables.sessionId,
date: variables.date,
userPrompt: userPromptTruncated,
});
}
/**
* Render tool message for SDK processing
*/
export function renderToolMessage(
variables: ToolMessageVariables
): string {
// Auto-truncate userPrompt and toolResponse
const userPromptTruncated = truncate(
variables.userPrompt,
HOOK_CONFIG.maxUserPromptLength
);
const toolResponseTruncated = truncate(
variables.toolResponse,
HOOK_CONFIG.maxToolResponseLength
);
// Format timestamp
const timeFormatted = formatTime(variables.timestamp);
return substituteVariables(PROMPTS.tool, {
toolName: variables.toolName,
toolResponse: toolResponseTruncated,
userPrompt: userPromptTruncated,
timestamp: variables.timestamp,
timeFormatted,
});
}
/**
* Render end message for session completion
*/
export function renderEndMessage(
variables: EndMessageVariables
): string {
return substituteVariables(PROMPTS.end, {
project: variables.project,
sessionId: variables.sessionId,
});
}
// =============================================================================
// GENERIC RENDERER (for convenience)
// =============================================================================
export type PromptType = 'system' | 'tool' | 'end';
export type PromptVariables<T extends PromptType> = T extends 'system'
? SystemPromptVariables
: T extends 'tool'
? ToolMessageVariables
: T extends 'end'
? EndMessageVariables
: never;
/**
* Generic prompt renderer - dispatches to specific renderer based on type
*/
export function renderPrompt<T extends PromptType>(
type: T,
variables: PromptVariables<T>
): string {
switch (type) {
case 'system':
return renderSystemPrompt(variables as SystemPromptVariables);
case 'tool':
return renderToolMessage(variables as ToolMessageVariables);
case 'end':
return renderEndMessage(variables as EndMessageVariables);
default:
throw new Error(`Unknown prompt type: ${type}`);
}
}
// =============================================================================
// EXPORTS
// =============================================================================
export { HOOK_CONFIG, PROMPTS };
+306
View File
@@ -0,0 +1,306 @@
/**
* Hook Prompts Configuration
*
* Centralized configuration for all streaming hook prompts.
* This is the SINGLE SOURCE OF TRUTH for hook prompt content.
*
* EDITING GUIDE:
* - Use {{variableName}} for template variables
* - Available variables are listed in each prompt's interface
* - All prompts are processed through renderPrompt() function
* - Changes here apply to all hooks automatically after rebuild
*
* LIFECYCLE FLOW:
* 1. user-prompt-submit: Initializes SDK session with systemPrompt
* 2. post-tool-use: Feeds tool responses using toolMessage (repeats N times)
* 3. stop: Ends session and requests overview using endMessage
*/
// =============================================================================
// TYPE DEFINITIONS
// =============================================================================
export interface SystemPromptVariables {
project: string;
sessionId: string;
date: string;
userPrompt: string; // Auto-truncated to maxUserPromptLength
}
export interface ToolMessageVariables {
toolName: string;
toolResponse: string; // Auto-truncated to maxToolResponseLength
userPrompt: string; // Auto-truncated to maxUserPromptLength
timestamp: string; // Full ISO timestamp
timeFormatted: string; // HH:MM:SS format
}
export interface EndMessageVariables {
project: string;
sessionId: string;
}
// =============================================================================
// SHARED CONFIGURATION
// =============================================================================
export const HOOK_CONFIG = {
// Truncation limits for template variables
maxUserPromptLength: 200,
maxToolResponseLength: 20000,
// SDK configuration (used by hooks)
sdk: {
model: 'claude-sonnet-4-5',
allowedTools: ['Bash'],
maxTokensSystem: 8192,
maxTokensTool: 8192,
maxTokensEnd: 2048,
},
} as const;
// =============================================================================
// PHASE 1: SYSTEM PROMPT (user-prompt-submit-streaming.js)
// =============================================================================
/**
* System prompt that initializes the SDK session.
* Instructs the SDK on how to process tool responses and store memories.
*
* Variables:
* - {{project}}: Project name (from cwd basename)
* - {{sessionId}}: Claude Code session ID
* - {{date}}: Current date (YYYY-MM-DD)
* - {{userPrompt}}: User's initial prompt (truncated to 200 chars)
*/
export const SYSTEM_PROMPT = `You are a semantic memory compressor for claude-mem. You process tool responses from an active Claude Code session and store the important ones as searchable, hierarchical memories.
# SESSION CONTEXT
- Project: {{project}}
- Session: {{sessionId}}
- Date: {{date}}
- User Request: "{{userPrompt}}"
# YOUR JOB
## FIRST: Generate Session Title
IMMEDIATELY generate a title and subtitle for this session based on the user request.
Use this bash command:
\`\`\`bash
claude-mem update-session-metadata \\
--project "{{project}}" \\
--session "{{sessionId}}" \\
--title "Short title (3-6 words)" \\
--subtitle "One sentence description (max 20 words)"
\`\`\`
Example for "Help me add dark mode to my app":
- Title: "Dark Mode Implementation"
- Subtitle: "Adding theme toggle and dark color scheme support to the application"
## THEN: Process Tool Responses
You will receive a stream of tool responses. For each one:
1. ANALYZE: Does this contain information worth remembering?
2. DECIDE: Should I store this or skip it?
3. EXTRACT: What are the key semantic concepts?
4. DECOMPOSE: Break into title + subtitle + atomic facts + narrative
5. STORE: Use bash to save the hierarchical memory
6. TRACK: Keep count of stored memories (001, 002, 003...)
# WHAT TO STORE
Store these:
- File contents with logic, algorithms, or patterns
- Search results revealing project structure
- Build errors or test failures with context
- Code revealing architecture or design decisions
- Git diffs with significant changes
- Command outputs showing system state
Skip these:
- Simple status checks (git status with no changes)
- Trivial edits (one-line config changes)
- Repeated operations
- Binary data or noise
- Anything without semantic value
# HIERARCHICAL MEMORY FORMAT
Each memory has FOUR components:
## 1. TITLE (3-8 words)
A scannable headline that captures the core action or topic.
Examples:
- "SDK Transcript Cleanup Implementation"
- "Hook System Architecture Analysis"
- "ChromaDB Migration Planning"
## 2. SUBTITLE (max 24 words)
A concise, memorable summary that captures the essence of the change.
Examples:
- "Automatic transcript cleanup after SDK session completion prevents memory conversations from appearing in UI history"
- "Four lifecycle hooks coordinate session events: start, prompt submission, tool processing, and completion"
- "Data migration from SQLite to ChromaDB enables semantic search across compressed conversation memories"
Guidelines:
- Clear and descriptive
- Focus on the outcome or benefit
- Use active voice when possible
- Keep it professional and informative
## 3. ATOMIC FACTS (3-7 facts, 50-150 chars each)
Individual, searchable statements that can be vector-embedded separately.
Each fact is ONE specific piece of information.
Examples:
- "stop-streaming.js: Auto-deletes SDK transcripts after completion"
- "Path format: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl"
- "Uses fs.unlink with graceful error handling for missing files"
- "Checks two transcript path formats for backward compatibility"
Guidelines:
- Start with filename or component when relevant
- Be specific: include paths, function names, actual values
- Each fact stands alone (no pronouns like "it" or "this")
- 50-150 characters target
- Focus on searchable technical details
## 4. NARRATIVE (512-1024 tokens, same as current format)
The full contextual story for deep dives:
"In the {{project}} project, [action taken]. [Technical details: files, functions, concepts]. [Why this matters]."
This is the detailed explanation for when someone needs full context.
# STORAGE COMMAND FORMAT
Store using this EXACT bash command structure:
\`\`\`bash
claude-mem store-memory \\
--id "{{project}}_{{sessionId}}_{{date}}_001" \\
--title "Your Title Here" \\
--subtitle "Your concise subtitle here" \\
--facts '["Fact 1 here", "Fact 2 here", "Fact 3 here"]' \\
--concepts '["concept1", "concept2", "concept3"]' \\
--files '["path/to/file1.js", "path/to/file2.ts"]' \\
--project "{{project}}" \\
--session "{{sessionId}}" \\
--date "{{date}}"
\`\`\`
CRITICAL FORMATTING RULES:
- Use single quotes around JSON arrays: --facts '["item1", "item2"]'
- Use double quotes inside the JSON arrays: "item"
- Use double quotes around simple string values: --title "Title"
- Escape any quotes in the content properly
- Sequential numbering: 001, 002, 003, etc.
Concepts: 2-5 broad categories (e.g., "hooks", "storage", "async-processing")
Files: Actual file paths touched (e.g., "hooks/stop-streaming.js")
# EXAMPLE MEMORY
Tool response shows: [Read file hooks/stop-streaming.js with 167 lines of code implementing SDK cleanup]
Your storage command:
\`\`\`bash
claude-mem store-memory \\
--id "claude-mem_abc123_2025-10-01_001" \\
--title "SDK Transcript Auto-Cleanup" \\
--subtitle "Automatic deletion of SDK transcripts after completion prevents memory conversations from appearing in UI history" \\
--facts '["stop-streaming.js: Deletes SDK transcript after overview generation", "Path: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl", "Uses fs.unlink with error handling for missing files", "Prevents memory conversations from polluting Claude Code UI"]' \\
--concepts '["cleanup", "SDK-lifecycle", "UX", "file-management"]' \\
--files '["hooks/stop-streaming.js"]' \\
--project "claude-mem" \\
--session "abc123" \\
--date "2025-10-01"
\`\`\`
# STATE TRACKING
CRITICAL: Keep track of your memory counter across all tool messages.
- Start at 001
- Increment for each stored memory
- Never repeat numbers
- Each session has separate numbering
# SESSION END
At the end (when I send "SESSION ENDING"), generate an overview using:
\`\`\`bash
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "2-3 sentence overview"
\`\`\`
# IMPORTANT REMINDERS
- You're processing a DIFFERENT Claude Code session (not your own)
- Use Bash tool to call claude-mem commands
- Keep subtitles clear and informative (max 24 words)
- Each fact is ONE specific thing (not multiple ideas)
- Be selective - quality over quantity
- Always increment memory numbers
- Facts should be searchable (specific file names, paths, functions)
Ready for tool responses.`;
// =============================================================================
// PHASE 2: TOOL MESSAGE (post-tool-use-streaming.js)
// =============================================================================
/**
* Message format for each tool response sent to the SDK.
* The SDK analyzes this and decides whether to store a memory.
*
* Variables:
* - {{timeFormatted}}: Time portion of timestamp (HH:MM:SS)
* - {{toolName}}: Name of the tool that was used
* - {{userPrompt}}: User's original prompt (truncated to 200 chars)
* - {{toolResponse}}: Full tool response (truncated to 20000 chars)
*/
export const TOOL_MESSAGE = `# Tool Response {{timeFormatted}}
Tool: {{toolName}}
User Context: "{{userPrompt}}"
\`\`\`
{{toolResponse}}
\`\`\`
Analyze and store if meaningful.`;
// =============================================================================
// PHASE 3: END MESSAGE (stop-streaming.js)
// =============================================================================
/**
* Message sent to SDK when session ends.
* Requests the SDK to generate and store a session overview.
*
* Variables:
* - {{project}}: Project name
* - {{sessionId}}: Claude Code session ID
*/
export const END_MESSAGE = `# SESSION ENDING
Review our entire conversation. Generate a concise 2-3 sentence overview of what was accomplished.
Store it using Bash:
\`\`\`bash
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "YOUR_OVERVIEW_HERE"
\`\`\`
Focus on: what was done, current state, key decisions, outcomes.`;
// =============================================================================
// EXPORTS
// =============================================================================
export const PROMPTS = {
system: SYSTEM_PROMPT,
tool: TOOL_MESSAGE,
end: END_MESSAGE,
} as const;
@@ -0,0 +1,491 @@
/**
* Context Templates for Human-Readable Formatting
*
* Essential templates for user-facing messages in the memory system.
* Focused on session start messages, error handling, and operation feedback.
* Previously included Handlebars templates for session start formatting; current
* version renders directly via console for clarity and performance.
*/
import { formatRelativeTime, parseTimestamp } from '../../../lib/time-utils.js';
// =============================================================================
// TERMINAL WIDTH & WORD WRAPPING
// =============================================================================
/**
* Determines target wrap width based on:
* 1) CLAUDE_MEM_WRAP_WIDTH env override
* 2) TTY columns (capped at 120)
* 3) Fallback default of 80
*/
function getWrapWidth(): number {
const env = process.env.CLAUDE_MEM_WRAP_WIDTH;
if (env) {
const n = parseInt(env, 10);
if (!Number.isNaN(n) && n > 40 && n <= 200) return n;
}
// Default to classic 80 columns unless overridden
return 80;
}
/**
* Wrap a single logical line to the given width, preserving leading indentation.
* Also avoids wrapping pure separator lines (====, ----, etc.).
*/
function wrapSingleLine(line: string, width: number): string {
if (!line) return '';
// Don't wrap long separator lines
if (/^[\-=\u2014_\u2500\u2550]{5,}$/.test(line.trim())) return line;
// If already short enough, return as-is
if (line.length <= width) return line;
const indentMatch = line.match(/^\s*/);
const indent = indentMatch ? indentMatch[0] : '';
const content = line.slice(indent.length);
const avail = Math.max(10, width - indent.length); // keep some minimum
const words = content.split(/(\s+)/); // keep whitespace tokens
const out: string[] = [];
let current = '';
const pushLine = () => {
out.push(indent + current.trimEnd());
current = '';
};
for (const token of words) {
if (token === '') continue;
// If token itself is longer than available width, hard-break it
if (!/\s/.test(token) && token.length > avail) {
if (current.trim().length > 0) pushLine();
let start = 0;
while (start < token.length) {
const chunk = token.slice(start, start + avail);
out.push(indent + chunk);
start += avail;
}
current = '';
continue;
}
if (indent.length + current.length + token.length > width) {
pushLine();
}
current += token;
}
if (current.trim().length > 0 || out.length === 0) pushLine();
return out.join('\n');
}
/**
* Wrap a block of text (possibly multi-line) to the given width.
* Preserves blank lines and wraps each line independently.
*/
function wrapText(text: string, width: number): string {
if (!text) return '';
return text
.split('\n')
.map((line) => wrapSingleLine(line, width))
.join('\n');
}
/** Create a full-width horizontal line with the given character */
function makeLine(char: string = '─', width: number = getWrapWidth()): string {
if (!char || char.length === 0) char = '-';
// Repeat and slice to exact width to avoid multi-column surprises
return char.repeat(width).slice(0, width);
}
// =============================================================================
// SESSION START MESSAGES
// =============================================================================
/**
* Creates a completion message after context operations
*/
export function createCompletionMessage(
operation: string,
count?: number,
details?: string
): string {
const countInfo = count !== undefined ? ` (${count} items)` : '';
const detailInfo = details ? `\n${details}` : '';
const width = getWrapWidth();
return wrapText(
`${operation} completed successfully${countInfo}${detailInfo}`,
width
);
}
// =============================================================================
// ERROR MESSAGES (USER-FRIENDLY)
// =============================================================================
/**
* Creates user-friendly error messages with helpful suggestions
*/
export function createUserFriendlyError(
operation: string,
error: string,
suggestion?: string
): string {
const suggestionText = suggestion ? `\n\n💡 ${suggestion}` : '';
const width = getWrapWidth();
return wrapText(
`${operation} encountered an issue: ${error}${suggestionText}`,
width
);
}
/**
* Common error scenarios with built-in suggestions
*/
export const ERROR_SCENARIOS = {
NO_MEMORIES: (projectName: string) => ({
message: `No previous memories found for ${projectName}`,
suggestion:
'This appears to be your first session. Memories will be created as you work.',
}),
CONNECTION_FAILED: () => ({
message: 'Could not connect to memory system',
suggestion:
'Try restarting Claude Code or check if the MCP server is properly configured.',
}),
SEARCH_FAILED: (query: string) => ({
message: `Search for "${query}" didn't return any results`,
suggestion:
'Try using different keywords or check if memories exist for this project.',
}),
LOAD_TIMEOUT: () => ({
message: 'Memory loading timed out',
suggestion:
'The operation is taking longer than expected. You can continue without loaded context.',
}),
};
/**
* Creates contextual error messages based on common scenarios
*/
export function createContextualError(
scenario: keyof typeof ERROR_SCENARIOS,
...args: string[]
): string {
const errorInfo = (ERROR_SCENARIOS[scenario] as any)(...args);
return createUserFriendlyError(
'Memory system',
errorInfo.message,
errorInfo.suggestion
);
}
// =============================================================================
// TIME AND DATE FORMATTING
// =============================================================================
/**
* Formats timestamps into human-readable "time ago" format
*/
export function formatTimeAgo(timestamp: string | Date): string {
const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
if (hours < 24) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
if (days < 7) return `${days} day${days > 1 ? 's' : ''} ago`;
return date.toLocaleDateString();
}
// =============================================================================
// SESSION START TEMPLATE SYSTEM (data processing only)
// =============================================================================
/**
* Interface for memory entry data structure
*/
interface MemoryEntry {
summary: string;
keywords?: string;
location?: string;
sessionId?: string;
number?: number;
}
/**
* Interface for grouped session memories
*/
interface SessionGroup {
sessionId: string;
memories: MemoryEntry[];
}
/**
* Interface for overview with timestamp
*/
interface OverviewEntry {
content: string;
timestamp?: Date;
timeAgo?: string;
sessionId?: string;
}
/**
* Interface for session-grouped overviews
*/
interface SessionOverviewGroup {
sessionId: string;
overviews: OverviewEntry[];
earliestTimestamp?: Date;
timeAgo?: string;
}
/**
* Pure data processing function - converts JSON objects into structured memory entries
* No formatting is done here, only data parsing and cleaning
*/
function processMemoryEntries(recentObjects: any[]): MemoryEntry[] {
if (recentObjects.length === 0) {
return [];
}
// Filter only memory type objects and convert to MemoryEntry format
return recentObjects
.filter((obj) => obj.type === 'memory')
.map((obj) => {
const entry: MemoryEntry = {
summary: obj.text || '',
sessionId: obj.session_id || '',
};
// Add optional fields if present
if (obj.keywords) {
entry.keywords = obj.keywords;
}
if (obj.document_id && !obj.document_id.includes('Session:')) {
entry.location = obj.document_id;
}
return entry;
})
.filter((entry) => entry.summary.length > 0);
}
/**
* Groups memories by session ID and adds numbering
*/
function groupMemoriesBySession(memories: MemoryEntry[]): SessionGroup[] {
const sessionMap = new Map<string, MemoryEntry[]>();
// Group memories by session ID
memories.forEach((memory) => {
const sessionId = memory.sessionId;
if (sessionId) {
if (!sessionMap.has(sessionId)) {
sessionMap.set(sessionId, []);
}
sessionMap.get(sessionId)!.push(memory);
}
});
// Convert to session groups with numbering
return Array.from(sessionMap.entries()).map(
([sessionId, sessionMemories]) => {
const numberedMemories = sessionMemories.map((memory, index) => ({
...memory,
number: index + 1,
}));
return {
sessionId,
memories: numberedMemories,
};
}
);
}
/**
* Groups overviews by session ID and calculates session timestamps
*/
function groupOverviewsBySession(
overviews: OverviewEntry[]
): SessionOverviewGroup[] {
const sessionMap = new Map<string, OverviewEntry[]>();
// Group overviews by session ID
overviews.forEach((overview) => {
const sessionId = overview.sessionId || 'unknown';
if (!sessionMap.has(sessionId)) {
sessionMap.set(sessionId, []);
}
sessionMap.get(sessionId)!.push(overview);
});
// Convert to session groups with timestamps
return Array.from(sessionMap.entries()).map(
([sessionId, sessionOverviews]) => {
// Find the earliest timestamp in this session's overviews
const timestamps = sessionOverviews
.map((o) => o.timestamp)
.filter((t): t is Date => t !== undefined)
.sort((a, b) => a.getTime() - b.getTime());
const group: SessionOverviewGroup = {
sessionId,
overviews: sessionOverviews,
};
// Add session-level timestamp if available
if (timestamps.length > 0) {
group.earliestTimestamp = timestamps[0];
group.timeAgo = formatRelativeTime(timestamps[0]);
}
return group;
}
);
}
/**
* Renders the complete session start template with provided data using Handlebars
* Data processing is separated from presentation - template controls the format
*/
// Intentionally removed Handlebars-based renderer; console output is handled by
// outputSessionStartContent() below.
/**
* Outputs session start content using dual streams:
* - stdout (console.log) -> Claude's context only (granular memories)
* - stderr (console.error) -> User visible (clean overviews)
*/
export function outputSessionStartContent(params: {
projectName: string;
memoryCount: number;
lastSessionTime?: string;
recentObjects: any[];
}): void {
const { projectName, memoryCount, lastSessionTime, recentObjects } = params;
const width = getWrapWidth();
// Start with current date and time at the top
const now = new Date();
const dateTimeFormatted = now.toLocaleString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short',
});
console.log('');
console.log(wrapText(`📅 ${dateTimeFormatted}`, width));
console.log(makeLine('─', width));
// Extract overviews for user display - get more to show session grouping
const overviews = extractOverviews(recentObjects, 10, projectName);
// Debug: Log what we're getting
console.error(`[DEBUG] recentObjects has ${recentObjects.length} items`);
console.error(`[DEBUG] overviews extracted: ${overviews.length}`);
// Process memory entries for Claude context
const memories = processMemoryEntries(recentObjects);
// Helper to split and normalize keywords into a map (lowercased -> original)
const splitKeywordsInto = (kw: string, dest: Map<string, string>) => {
const tokens =
kw.includes(',') || kw.includes('\n') ? kw.split(/[\n,]+/) : [kw];
for (const t of tokens) {
const trimmed = t.trim();
if (!trimmed) continue;
const key = trimmed.toLowerCase();
if (!dest.has(key)) dest.set(key, trimmed);
}
};
// Output memories first, then overviews at bottom, all sorted oldest to newest
if (memories.length > 0) {
const sessionGroups = groupMemoriesBySession(memories);
console.log('');
console.log('');
console.log(wrapText('📚 Memories', width));
sessionGroups.forEach((group) => {
console.log(makeLine('─', width));
console.log('');
console.log(wrapText(`🔍 ${group.sessionId}`, width));
// Collect keywords for this session as we iterate its memories
const groupKeywordMap = new Map<string, string>();
group.memories.forEach((memory) => {
console.log('');
console.log(wrapText(`${memory.number}. ${memory.summary}`, width));
if (memory.keywords)
splitKeywordsInto(memory.keywords, groupKeywordMap);
});
// Print this session's aggregated keywords under the session block
const groupKeywords = Array.from(groupKeywordMap.values());
if (groupKeywords.length > 0) {
console.log('');
console.log(wrapText(`🏷️ ${groupKeywords.join(', ')}`, width));
}
console.log('');
});
}
// Overview section at bottom with session grouping
if (overviews.length > 0) {
const sessionGroups = groupOverviewsBySession(overviews);
// Sort groups by timestamp, oldest first for chronological reading order
sessionGroups.sort((a, b) => {
const timeA = a.earliestTimestamp?.getTime() || 0;
const timeB = b.earliestTimestamp?.getTime() || 0;
return timeA - timeB; // Ascending order (oldest first)
});
console.log('');
console.log(wrapText('🧠 Overviews', width));
console.log(makeLine('─', width));
// Match the memories section layout: session header, numbered items, per-session separator
sessionGroups.forEach((group) => {
console.log('');
console.log(wrapText(`🔍 ${group.sessionId}`, width));
group.overviews.forEach((overview, index) => {
console.log('');
console.log(wrapText(`${index + 1}. ${overview.content}`, width));
console.log('');
if (overview.timeAgo) {
console.log(wrapText(`📅 ${overview.timeAgo}`, width));
}
});
console.log('');
console.log(makeLine('─', width));
});
} else if (memories.length === 0) {
console.log(
wrapText(`🧠 No recent context found for ${projectName}`, width)
);
}
}
+373
View File
@@ -0,0 +1,373 @@
import { join, dirname, sep } from 'path';
import { homedir } from 'os';
import { existsSync, statSync } from 'fs';
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
/**
* PathDiscovery Service - Central path resolution for claude-mem
*
* Handles dynamic discovery of all required paths across different installation scenarios:
* - npm global installs, local installs, and development environments
* - Cross-platform path resolution (Windows, macOS, Linux)
* - Environment variable overrides for customization
* - Package resource discovery (hooks, commands)
*/
export class PathDiscovery {
private static instance: PathDiscovery | null = null;
// Cached paths for performance
private _dataDirectory: string | null = null;
private _packageRoot: string | null = null;
private _claudeConfigDirectory: string | null = null;
/**
* Get singleton instance
*/
static getInstance(): PathDiscovery {
if (!PathDiscovery.instance) {
PathDiscovery.instance = new PathDiscovery();
}
return PathDiscovery.instance;
}
// =============================================================================
// DATA DIRECTORIES - Where claude-mem stores its data
// =============================================================================
/**
* Base data directory for claude-mem
* Environment override: CLAUDE_MEM_DATA_DIR
*/
getDataDirectory(): string {
if (this._dataDirectory) return this._dataDirectory;
this._dataDirectory = process.env.CLAUDE_MEM_DATA_DIR || join(homedir(), '.claude-mem');
return this._dataDirectory;
}
/**
* Archives directory for compressed sessions
*/
getArchivesDirectory(): string {
return join(this.getDataDirectory(), 'archives');
}
/**
* Hooks directory where claude-mem hooks are installed
*/
getHooksDirectory(): string {
return join(this.getDataDirectory(), 'hooks');
}
/**
* Logs directory for claude-mem operation logs
*/
getLogsDirectory(): string {
return join(this.getDataDirectory(), 'logs');
}
/**
* Index directory for memory indexing
*/
getIndexDirectory(): string {
return this.getDataDirectory();
}
/**
* Index file path for memory indexing
*/
getIndexPath(): string {
return join(this.getIndexDirectory(), 'claude-mem-index.jsonl');
}
/**
* Trash directory for smart trash feature
*/
getTrashDirectory(): string {
return join(this.getDataDirectory(), 'trash');
}
/**
* Backups directory for configuration backups
*/
getBackupsDirectory(): string {
return join(this.getDataDirectory(), 'backups');
}
/**
* Chroma database directory
*/
getChromaDirectory(): string {
return join(this.getDataDirectory(), 'chroma');
}
/**
* Project-specific archive directory
*/
getProjectArchiveDirectory(projectName: string): string {
return join(this.getArchivesDirectory(), projectName);
}
/**
* User settings file path
*/
getUserSettingsPath(): string {
return join(this.getDataDirectory(), 'settings.json');
}
// =============================================================================
// CLAUDE INTEGRATION PATHS - Where Claude Code expects configuration
// =============================================================================
/**
* Claude configuration directory
* Environment override: CLAUDE_CONFIG_DIR
*/
getClaudeConfigDirectory(): string {
if (this._claudeConfigDirectory) return this._claudeConfigDirectory;
this._claudeConfigDirectory = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
return this._claudeConfigDirectory;
}
/**
* Claude settings file path
*/
getClaudeSettingsPath(): string {
return join(this.getClaudeConfigDirectory(), 'settings.json');
}
/**
* Claude commands directory where custom commands are installed
*/
getClaudeCommandsDirectory(): string {
return join(this.getClaudeConfigDirectory(), 'commands');
}
/**
* CLAUDE.md instructions file path
*/
getClaudeMdPath(): string {
return join(this.getClaudeConfigDirectory(), 'CLAUDE.md');
}
/**
* MCP configuration file path (user-level)
*/
getMcpConfigPath(): string {
return join(homedir(), '.claude.json');
}
/**
* MCP configuration file path (project-level)
*/
getProjectMcpConfigPath(): string {
return join(process.cwd(), '.mcp.json');
}
// =============================================================================
// PACKAGE DISCOVERY - Find claude-mem package resources
// =============================================================================
/**
* Discover the claude-mem package root directory
*/
getPackageRoot(): string {
if (this._packageRoot) return this._packageRoot;
// Method 1: Try require.resolve for package.json
try {
const packageJsonPath = require.resolve('claude-mem/package.json');
this._packageRoot = dirname(packageJsonPath);
return this._packageRoot;
} catch {
// Continue to next method
}
// Method 2: Walk up from current module location
const currentFile = fileURLToPath(import.meta.url);
let currentDir = dirname(currentFile);
for (let i = 0; i < 10; i++) {
const packageJsonPath = join(currentDir, 'package.json');
if (existsSync(packageJsonPath)) {
const packageJson = require(packageJsonPath);
if (packageJson.name === 'claude-mem') {
this._packageRoot = currentDir;
return this._packageRoot;
}
}
const parentDir = dirname(currentDir);
if (parentDir === currentDir) break;
currentDir = parentDir;
}
// Method 3: Try npm list command
try {
const npmOutput = execSync('npm list -g claude-mem --json 2>/dev/null || npm list claude-mem --json 2>/dev/null', {
encoding: 'utf8'
});
const npmData = JSON.parse(npmOutput);
if (npmData.dependencies?.['claude-mem']?.resolved) {
this._packageRoot = dirname(npmData.dependencies['claude-mem'].resolved);
return this._packageRoot;
}
} catch {
// Continue to error
}
throw new Error('Cannot locate claude-mem package root. Ensure claude-mem is properly installed.');
}
/**
* Find hook templates directory in the installed package
*
* This returns the SOURCE templates directory that gets copied during installation
* to the runtime hooks directory (~/.claude-mem/hooks/)
*/
findPackageHookTemplatesDirectory(): string {
const packageRoot = this.getPackageRoot();
const hookTemplatesDir = join(packageRoot, 'hook-templates');
// Verify it contains expected hook template files
const requiredHookTemplates = [
'session-start.js',
'stop.js',
'user-prompt-submit.js',
'post-tool-use.js'
];
for (const hookTemplateFile of requiredHookTemplates) {
if (!existsSync(join(hookTemplatesDir, hookTemplateFile))) {
throw new Error(`Package hook-templates directory missing required template file: ${hookTemplateFile}`);
}
}
return hookTemplatesDir;
}
/**
* Find commands directory in the installed package
*/
findPackageCommandsDirectory(): string {
const packageRoot = this.getPackageRoot();
const commandsDir = join(packageRoot, 'commands');
// Verify it contains expected command files
const requiredCommands = ['save.md'];
for (const commandFile of requiredCommands) {
if (!existsSync(join(commandsDir, commandFile))) {
throw new Error(`Package commands directory missing required file: ${commandFile}`);
}
}
return commandsDir;
}
// =============================================================================
// UTILITY METHODS
// =============================================================================
/**
* Ensure a directory exists, creating it if necessary
*/
ensureDirectory(dirPath: string): void {
if (!existsSync(dirPath)) {
require('fs').mkdirSync(dirPath, { recursive: true });
}
}
/**
* Ensure multiple directories exist
*/
ensureDirectories(dirPaths: string[]): void {
dirPaths.forEach(dirPath => this.ensureDirectory(dirPath));
}
/**
* Create all claude-mem data directories
*/
ensureAllDataDirectories(): void {
this.ensureDirectories([
this.getDataDirectory(),
this.getArchivesDirectory(),
this.getHooksDirectory(),
this.getLogsDirectory(),
this.getTrashDirectory(),
this.getBackupsDirectory(),
this.getChromaDirectory()
]);
}
/**
* Create all Claude integration directories
*/
ensureAllClaudeDirectories(): void {
this.ensureDirectories([
this.getClaudeConfigDirectory(),
this.getClaudeCommandsDirectory()
]);
}
/**
* Extract project name from a file path (improved from PathResolver)
*/
static extractProjectName(filePath: string): string {
const pathParts = filePath.split(sep);
// Look for common project indicators
const projectIndicators = ['src', 'lib', 'app', 'project', 'workspace'];
for (let i = pathParts.length - 1; i >= 0; i--) {
if (projectIndicators.includes(pathParts[i]) && i > 0) {
return pathParts[i - 1];
}
}
// Fallback to directory containing the file
if (pathParts.length > 1) {
return pathParts[pathParts.length - 2];
}
return 'unknown-project';
}
/**
* Get current project directory name
* Uses git repository root's basename if in a git repo, otherwise falls back to cwd basename
*/
static getCurrentProjectName(): string {
try {
const gitRoot = execSync('git rev-parse --show-toplevel', {
cwd: process.cwd(),
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore']
}).trim();
return require('path').basename(gitRoot);
} catch {
return require('path').basename(process.cwd());
}
}
/**
* Create a timestamped backup filename
*/
static createBackupFilename(originalPath: string): string {
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, '-')
.replace('T', '_')
.slice(0, 19);
return `${originalPath}.backup.${timestamp}`;
}
/**
* Check if a path exists and is accessible
*/
static isPathAccessible(path: string): boolean {
return existsSync(path) && statSync(path).isDirectory();
}
}
+179
View File
@@ -0,0 +1,179 @@
import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
import { PathDiscovery } from '../path-discovery.js';
export interface Migration {
version: number;
up: (db: Database.Database) => void;
down?: (db: Database.Database) => void;
}
let dbInstance: Database.Database | null = null;
/**
* SQLite Database singleton with migration support and optimized settings
*/
export class DatabaseManager {
private static instance: DatabaseManager;
private db: Database.Database | null = null;
private migrations: Migration[] = [];
static getInstance(): DatabaseManager {
if (!DatabaseManager.instance) {
DatabaseManager.instance = new DatabaseManager();
}
return DatabaseManager.instance;
}
/**
* Register a migration to be run during initialization
*/
registerMigration(migration: Migration): void {
this.migrations.push(migration);
// Keep migrations sorted by version
this.migrations.sort((a, b) => a.version - b.version);
}
/**
* Initialize database connection with optimized settings
*/
async initialize(): Promise<Database.Database> {
if (this.db) {
return this.db;
}
// Ensure the data directory exists
const dataDir = PathDiscovery.getInstance().getDataDirectory();
fs.mkdirSync(dataDir, { recursive: true });
const dbPath = path.join(dataDir, 'claude-mem.db');
this.db = new Database(dbPath);
// Apply optimized SQLite settings
this.db.pragma('journal_mode = WAL');
this.db.pragma('synchronous = NORMAL');
this.db.pragma('foreign_keys = ON');
this.db.pragma('temp_store = memory');
this.db.pragma('mmap_size = 268435456'); // 256MB
this.db.pragma('cache_size = 10000');
// Initialize schema_versions table
this.initializeSchemaVersions();
// Run migrations
await this.runMigrations();
dbInstance = this.db;
return this.db;
}
/**
* Get the current database connection
*/
getConnection(): Database.Database {
if (!this.db) {
throw new Error('Database not initialized. Call initialize() first.');
}
return this.db;
}
/**
* Execute a function within a transaction
*/
withTransaction<T>(fn: (db: Database.Database) => T): T {
const db = this.getConnection();
const transaction = db.transaction(fn);
return transaction(db);
}
/**
* Close the database connection
*/
close(): void {
if (this.db) {
this.db.close();
this.db = null;
dbInstance = null;
}
}
/**
* Initialize the schema_versions table
*/
private initializeSchemaVersions(): void {
if (!this.db) return;
this.db.exec(`
CREATE TABLE IF NOT EXISTS schema_versions (
id INTEGER PRIMARY KEY,
version INTEGER UNIQUE NOT NULL,
applied_at TEXT NOT NULL
)
`);
}
/**
* Run all pending migrations
*/
private async runMigrations(): Promise<void> {
if (!this.db) return;
const appliedVersions = this.db
.prepare('SELECT version FROM schema_versions ORDER BY version')
.all()
.map((row: any) => row.version);
const maxApplied = appliedVersions.length > 0 ? Math.max(...appliedVersions) : 0;
for (const migration of this.migrations) {
if (migration.version > maxApplied) {
console.log(`Applying migration ${migration.version}...`);
const transaction = this.db.transaction(() => {
migration.up(this.db!);
this.db!
.prepare('INSERT INTO schema_versions (version, applied_at) VALUES (?, ?)')
.run(migration.version, new Date().toISOString());
});
transaction();
console.log(`Migration ${migration.version} applied successfully`);
}
}
}
/**
* Get current schema version
*/
getCurrentVersion(): number {
if (!this.db) return 0;
const result = this.db
.prepare('SELECT MAX(version) as version FROM schema_versions')
.get() as { version: number } | undefined;
return result?.version || 0;
}
}
/**
* Get the global database instance (for compatibility)
*/
export function getDatabase(): Database.Database {
if (!dbInstance) {
throw new Error('Database not initialized. Call DatabaseManager.getInstance().initialize() first.');
}
return dbInstance;
}
/**
* Initialize and get database manager
*/
export async function initializeDatabase(): Promise<Database.Database> {
const manager = DatabaseManager.getInstance();
return await manager.initialize();
}
export { Database };
+229
View File
@@ -0,0 +1,229 @@
import { Database } from 'better-sqlite3';
import { getDatabase } from './Database.js';
import { DiagnosticRow, DiagnosticInput, normalizeTimestamp } from './types.js';
/**
* Data Access Object for diagnostic records
*/
export class DiagnosticsStore {
private db: Database.Database;
constructor(db?: Database.Database) {
this.db = db || getDatabase();
}
/**
* Create a new diagnostic record
*/
create(input: DiagnosticInput): DiagnosticRow {
const { isoString, epoch } = normalizeTimestamp(input.created_at);
const stmt = this.db.prepare(`
INSERT INTO diagnostics (
session_id, message, severity, created_at, created_at_epoch, project, origin
) VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const info = stmt.run(
input.session_id || null,
input.message,
input.severity || 'warn',
isoString,
epoch,
input.project,
input.origin || 'compressor'
);
return this.getById(info.lastInsertRowid as number)!;
}
/**
* Get diagnostic by primary key
*/
getById(id: number): DiagnosticRow | null {
const stmt = this.db.prepare('SELECT * FROM diagnostics WHERE id = ?');
return stmt.get(id) as DiagnosticRow || null;
}
/**
* Get diagnostics for a specific session
*/
getBySessionId(sessionId: string): DiagnosticRow[] {
const stmt = this.db.prepare(`
SELECT * FROM diagnostics
WHERE session_id = ?
ORDER BY created_at_epoch DESC
`);
return stmt.all(sessionId) as DiagnosticRow[];
}
/**
* Get recent diagnostics for a project
*/
getRecentForProject(project: string, limit = 10): DiagnosticRow[] {
const stmt = this.db.prepare(`
SELECT * FROM diagnostics
WHERE project = ?
ORDER BY created_at_epoch DESC
LIMIT ?
`);
return stmt.all(project, limit) as DiagnosticRow[];
}
/**
* Get recent diagnostics across all projects
*/
getRecent(limit = 10): DiagnosticRow[] {
const stmt = this.db.prepare(`
SELECT * FROM diagnostics
ORDER BY created_at_epoch DESC
LIMIT ?
`);
return stmt.all(limit) as DiagnosticRow[];
}
/**
* Get diagnostics by severity level
*/
getBySeverity(severity: 'info' | 'warn' | 'error', limit?: number): DiagnosticRow[] {
const query = limit
? 'SELECT * FROM diagnostics WHERE severity = ? ORDER BY created_at_epoch DESC LIMIT ?'
: 'SELECT * FROM diagnostics WHERE severity = ? ORDER BY created_at_epoch DESC';
const stmt = this.db.prepare(query);
const params = limit ? [severity, limit] : [severity];
return stmt.all(...params) as DiagnosticRow[];
}
/**
* Get diagnostics by origin
*/
getByOrigin(origin: string, limit?: number): DiagnosticRow[] {
const query = limit
? 'SELECT * FROM diagnostics WHERE origin = ? ORDER BY created_at_epoch DESC LIMIT ?'
: 'SELECT * FROM diagnostics WHERE origin = ? ORDER BY created_at_epoch DESC';
const stmt = this.db.prepare(query);
const params = limit ? [origin, limit] : [origin];
return stmt.all(...params) as DiagnosticRow[];
}
/**
* Search diagnostics by message content
*/
searchByMessage(query: string, project?: string, limit = 20): DiagnosticRow[] {
let sql = 'SELECT * FROM diagnostics WHERE message LIKE ?';
const params: any[] = [`%${query}%`];
if (project) {
sql += ' AND project = ?';
params.push(project);
}
sql += ' ORDER BY created_at_epoch DESC LIMIT ?';
params.push(limit);
const stmt = this.db.prepare(sql);
return stmt.all(...params) as DiagnosticRow[];
}
/**
* Count total diagnostics
*/
count(): number {
const stmt = this.db.prepare('SELECT COUNT(*) as count FROM diagnostics');
const result = stmt.get() as { count: number };
return result.count;
}
/**
* Count diagnostics by project
*/
countByProject(project: string): number {
const stmt = this.db.prepare('SELECT COUNT(*) as count FROM diagnostics WHERE project = ?');
const result = stmt.get(project) as { count: number };
return result.count;
}
/**
* Count diagnostics by severity
*/
countBySeverity(severity: 'info' | 'warn' | 'error'): number {
const stmt = this.db.prepare('SELECT COUNT(*) as count FROM diagnostics WHERE severity = ?');
const result = stmt.get(severity) as { count: number };
return result.count;
}
/**
* Update a diagnostic record
*/
update(id: number, input: Partial<DiagnosticInput>): DiagnosticRow {
const existing = this.getById(id);
if (!existing) {
throw new Error(`Diagnostic with id ${id} not found`);
}
const { isoString, epoch } = normalizeTimestamp(input.created_at || existing.created_at);
const stmt = this.db.prepare(`
UPDATE diagnostics SET
message = ?, severity = ?, created_at = ?, created_at_epoch = ?, project = ?, origin = ?
WHERE id = ?
`);
stmt.run(
input.message || existing.message,
input.severity || existing.severity,
isoString,
epoch,
input.project || existing.project,
input.origin || existing.origin,
id
);
return this.getById(id)!;
}
/**
* Delete a diagnostic by ID
*/
deleteById(id: number): boolean {
const stmt = this.db.prepare('DELETE FROM diagnostics WHERE id = ?');
const info = stmt.run(id);
return info.changes > 0;
}
/**
* Delete diagnostics by session_id
*/
deleteBySessionId(sessionId: string): number {
const stmt = this.db.prepare('DELETE FROM diagnostics WHERE session_id = ?');
const info = stmt.run(sessionId);
return info.changes;
}
/**
* Get unique projects from diagnostics
*/
getUniqueProjects(): string[] {
const stmt = this.db.prepare('SELECT DISTINCT project FROM diagnostics ORDER BY project');
const rows = stmt.all() as { project: string }[];
return rows.map(row => row.project);
}
/**
* Get diagnostic summary stats
*/
getStats(): { total: number; info: number; warn: number; error: number } {
const stmt = this.db.prepare(`
SELECT
COUNT(*) as total,
COUNT(CASE WHEN severity = 'info' THEN 1 END) as info,
COUNT(CASE WHEN severity = 'warn' THEN 1 END) as warn,
COUNT(CASE WHEN severity = 'error' THEN 1 END) as error
FROM diagnostics
`);
return stmt.get() as { total: number; info: number; warn: number; error: number };
}
}
+287
View File
@@ -0,0 +1,287 @@
import { Database } from 'better-sqlite3';
import { getDatabase } from './Database.js';
import { MemoryRow, MemoryInput, normalizeTimestamp } from './types.js';
/**
* Data Access Object for memory records
*/
export class MemoryStore {
private db: Database.Database;
constructor(db?: Database.Database) {
this.db = db || getDatabase();
}
/**
* Create a new memory record
*/
create(input: MemoryInput): MemoryRow {
const { isoString, epoch } = normalizeTimestamp(input.created_at);
const stmt = this.db.prepare(`
INSERT INTO memories (
session_id, text, document_id, keywords, created_at, created_at_epoch,
project, archive_basename, origin, title, subtitle, facts, concepts, files_touched
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const info = stmt.run(
input.session_id,
input.text,
input.document_id || null,
input.keywords || null,
isoString,
epoch,
input.project,
input.archive_basename || null,
input.origin || 'transcript',
input.title || null,
input.subtitle || null,
input.facts || null,
input.concepts || null,
input.files_touched || null
);
return this.getById(info.lastInsertRowid as number)!;
}
/**
* Create multiple memory records in a transaction
*/
createMany(inputs: MemoryInput[]): MemoryRow[] {
const transaction = this.db.transaction((memories: MemoryInput[]) => {
const results: MemoryRow[] = [];
for (const memory of memories) {
results.push(this.create(memory));
}
return results;
});
return transaction(inputs);
}
/**
* Get memory by primary key
*/
getById(id: number): MemoryRow | null {
const stmt = this.db.prepare('SELECT * FROM memories WHERE id = ?');
return stmt.get(id) as MemoryRow || null;
}
/**
* Get memory by document_id
*/
getByDocumentId(documentId: string): MemoryRow | null {
const stmt = this.db.prepare('SELECT * FROM memories WHERE document_id = ?');
return stmt.get(documentId) as MemoryRow || null;
}
/**
* Check if a document_id already exists
*/
hasDocumentId(documentId: string): boolean {
const stmt = this.db.prepare('SELECT 1 FROM memories WHERE document_id = ? LIMIT 1');
return Boolean(stmt.get(documentId));
}
/**
* Get memories for a specific session
*/
getBySessionId(sessionId: string): MemoryRow[] {
const stmt = this.db.prepare(`
SELECT * FROM memories
WHERE session_id = ?
ORDER BY created_at_epoch DESC
`);
return stmt.all(sessionId) as MemoryRow[];
}
/**
* Get recent memories for a project
*/
getRecentForProject(project: string, limit = 10): MemoryRow[] {
const stmt = this.db.prepare(`
SELECT * FROM memories
WHERE project = ?
ORDER BY created_at_epoch DESC
LIMIT ?
`);
return stmt.all(project, limit) as MemoryRow[];
}
/**
* Get recent memories across all projects
*/
getRecent(limit = 10): MemoryRow[] {
const stmt = this.db.prepare(`
SELECT * FROM memories
ORDER BY created_at_epoch DESC
LIMIT ?
`);
return stmt.all(limit) as MemoryRow[];
}
/**
* Search memories by text content
*/
searchByText(query: string, project?: string, limit = 20): MemoryRow[] {
let sql = 'SELECT * FROM memories WHERE text LIKE ?';
const params: any[] = [`%${query}%`];
if (project) {
sql += ' AND project = ?';
params.push(project);
}
sql += ' ORDER BY created_at_epoch DESC LIMIT ?';
params.push(limit);
const stmt = this.db.prepare(sql);
return stmt.all(...params) as MemoryRow[];
}
/**
* Search memories by keywords
*/
searchByKeywords(keywords: string, project?: string, limit = 20): MemoryRow[] {
let sql = 'SELECT * FROM memories WHERE keywords LIKE ?';
const params: any[] = [`%${keywords}%`];
if (project) {
sql += ' AND project = ?';
params.push(project);
}
sql += ' ORDER BY created_at_epoch DESC LIMIT ?';
params.push(limit);
const stmt = this.db.prepare(sql);
return stmt.all(...params) as MemoryRow[];
}
/**
* Get memories by origin type
*/
getByOrigin(origin: string, limit?: number): MemoryRow[] {
const query = limit
? 'SELECT * FROM memories WHERE origin = ? ORDER BY created_at_epoch DESC LIMIT ?'
: 'SELECT * FROM memories WHERE origin = ? ORDER BY created_at_epoch DESC';
const stmt = this.db.prepare(query);
const params = limit ? [origin, limit] : [origin];
return stmt.all(...params) as MemoryRow[];
}
/**
* Get recent memories for a project filtered by origin
*/
getRecentForProjectByOrigin(project: string, origin: string, limit = 10): MemoryRow[] {
const stmt = this.db.prepare(`
SELECT * FROM memories
WHERE project = ? AND origin = ?
ORDER BY created_at_epoch DESC
LIMIT ?
`);
return stmt.all(project, origin, limit) as MemoryRow[];
}
/**
* Get last N memories for a project, sorted oldest to newest
*/
getLastNForProject(project: string, limit = 10): MemoryRow[] {
const stmt = this.db.prepare(`
SELECT * FROM (
SELECT * FROM memories
WHERE project = ?
ORDER BY created_at_epoch DESC
LIMIT ?
)
ORDER BY created_at_epoch ASC
`);
return stmt.all(project, limit) as MemoryRow[];
}
/**
* Count total memories
*/
count(): number {
const stmt = this.db.prepare('SELECT COUNT(*) as count FROM memories');
const result = stmt.get() as { count: number };
return result.count;
}
/**
* Count memories by project
*/
countByProject(project: string): number {
const stmt = this.db.prepare('SELECT COUNT(*) as count FROM memories WHERE project = ?');
const result = stmt.get(project) as { count: number };
return result.count;
}
/**
* Update a memory record
*/
update(id: number, input: Partial<MemoryInput>): MemoryRow {
const existing = this.getById(id);
if (!existing) {
throw new Error(`Memory with id ${id} not found`);
}
const { isoString, epoch } = normalizeTimestamp(input.created_at || existing.created_at);
const stmt = this.db.prepare(`
UPDATE memories SET
text = ?, document_id = ?, keywords = ?, created_at = ?, created_at_epoch = ?,
project = ?, archive_basename = ?, origin = ?, title = ?, subtitle = ?, facts = ?,
concepts = ?, files_touched = ?
WHERE id = ?
`);
stmt.run(
input.text || existing.text,
input.document_id !== undefined ? input.document_id : existing.document_id,
input.keywords !== undefined ? input.keywords : existing.keywords,
isoString,
epoch,
input.project || existing.project,
input.archive_basename !== undefined ? input.archive_basename : existing.archive_basename,
input.origin || existing.origin,
input.title !== undefined ? input.title : existing.title,
input.subtitle !== undefined ? input.subtitle : existing.subtitle,
input.facts !== undefined ? input.facts : existing.facts,
input.concepts !== undefined ? input.concepts : existing.concepts,
input.files_touched !== undefined ? input.files_touched : existing.files_touched,
id
);
return this.getById(id)!;
}
/**
* Delete a memory by ID
*/
deleteById(id: number): boolean {
const stmt = this.db.prepare('DELETE FROM memories WHERE id = ?');
const info = stmt.run(id);
return info.changes > 0;
}
/**
* Delete memories by session_id
*/
deleteBySessionId(sessionId: string): number {
const stmt = this.db.prepare('DELETE FROM memories WHERE session_id = ?');
const info = stmt.run(sessionId);
return info.changes;
}
/**
* Get unique projects from memories
*/
getUniqueProjects(): string[] {
const stmt = this.db.prepare('SELECT DISTINCT project FROM memories ORDER BY project');
const rows = stmt.all() as { project: string }[];
return rows.map(row => row.project);
}
}
+241
View File
@@ -0,0 +1,241 @@
import { Database } from 'better-sqlite3';
import { getDatabase } from './Database.js';
import { OverviewRow, OverviewInput, normalizeTimestamp } from './types.js';
/**
* Data Access Object for overview records
*/
export class OverviewStore {
private db: Database.Database;
constructor(db?: Database.Database) {
this.db = db || getDatabase();
}
/**
* Create a new overview record
*/
create(input: OverviewInput): OverviewRow {
const { isoString, epoch } = normalizeTimestamp(input.created_at);
const stmt = this.db.prepare(`
INSERT INTO overviews (
session_id, content, created_at, created_at_epoch, project, origin
) VALUES (?, ?, ?, ?, ?, ?)
`);
const info = stmt.run(
input.session_id,
input.content,
isoString,
epoch,
input.project,
input.origin || 'claude'
);
return this.getById(info.lastInsertRowid as number)!;
}
/**
* Create or replace an overview for a session (since one session should have one overview)
*/
upsert(input: OverviewInput): OverviewRow {
const existing = this.getBySessionId(input.session_id);
if (existing) {
return this.update(existing.id, input);
}
return this.create(input);
}
/**
* Get overview by primary key
*/
getById(id: number): OverviewRow | null {
const stmt = this.db.prepare('SELECT * FROM overviews WHERE id = ?');
return stmt.get(id) as OverviewRow || null;
}
/**
* Get overview by session_id
*/
getBySessionId(sessionId: string): OverviewRow | null {
const stmt = this.db.prepare('SELECT * FROM overviews WHERE session_id = ?');
return stmt.get(sessionId) as OverviewRow || null;
}
/**
* Get recent overviews for a project
*/
getRecentForProject(project: string, limit = 5): OverviewRow[] {
const stmt = this.db.prepare(`
SELECT * FROM overviews
WHERE project = ?
ORDER BY created_at_epoch DESC
LIMIT ?
`);
return stmt.all(project, limit) as OverviewRow[];
}
/**
* Get all overviews for a project (oldest to newest)
*/
getAllForProject(project: string): OverviewRow[] {
const stmt = this.db.prepare(`
SELECT * FROM overviews
WHERE project = ?
ORDER BY created_at_epoch ASC
`);
return stmt.all(project) as OverviewRow[];
}
/**
* Get recent overviews across all projects
*/
getRecent(limit = 5): OverviewRow[] {
const stmt = this.db.prepare(`
SELECT * FROM overviews
ORDER BY created_at_epoch DESC
LIMIT ?
`);
return stmt.all(limit) as OverviewRow[];
}
/**
* Search overviews by content
*/
searchByContent(query: string, project?: string, limit = 10): OverviewRow[] {
let sql = 'SELECT * FROM overviews WHERE content LIKE ?';
const params: any[] = [`%${query}%`];
if (project) {
sql += ' AND project = ?';
params.push(project);
}
sql += ' ORDER BY created_at_epoch DESC LIMIT ?';
params.push(limit);
const stmt = this.db.prepare(sql);
return stmt.all(...params) as OverviewRow[];
}
/**
* Get overviews by origin type
*/
getByOrigin(origin: string, limit?: number): OverviewRow[] {
const query = limit
? 'SELECT * FROM overviews WHERE origin = ? ORDER BY created_at_epoch DESC LIMIT ?'
: 'SELECT * FROM overviews WHERE origin = ? ORDER BY created_at_epoch DESC';
const stmt = this.db.prepare(query);
const params = limit ? [origin, limit] : [origin];
return stmt.all(...params) as OverviewRow[];
}
/**
* Count total overviews
*/
count(): number {
const stmt = this.db.prepare('SELECT COUNT(*) as count FROM overviews');
const result = stmt.get() as { count: number };
return result.count;
}
/**
* Count overviews by project
*/
countByProject(project: string): number {
const stmt = this.db.prepare('SELECT COUNT(*) as count FROM overviews WHERE project = ?');
const result = stmt.get(project) as { count: number };
return result.count;
}
/**
* Update an overview record
*/
update(id: number, input: Partial<OverviewInput>): OverviewRow {
const existing = this.getById(id);
if (!existing) {
throw new Error(`Overview with id ${id} not found`);
}
const { isoString, epoch } = normalizeTimestamp(input.created_at || existing.created_at);
const stmt = this.db.prepare(`
UPDATE overviews SET
content = ?, created_at = ?, created_at_epoch = ?, project = ?, origin = ?
WHERE id = ?
`);
stmt.run(
input.content || existing.content,
isoString,
epoch,
input.project || existing.project,
input.origin || existing.origin,
id
);
return this.getById(id)!;
}
/**
* Delete an overview by ID
*/
deleteById(id: number): boolean {
const stmt = this.db.prepare('DELETE FROM overviews WHERE id = ?');
const info = stmt.run(id);
return info.changes > 0;
}
/**
* Delete overview by session_id
*/
deleteBySessionId(sessionId: string): boolean {
const stmt = this.db.prepare('DELETE FROM overviews WHERE session_id = ?');
const info = stmt.run(sessionId);
return info.changes > 0;
}
/**
* Get unique projects from overviews
*/
getUniqueProjects(): string[] {
const stmt = this.db.prepare('SELECT DISTINCT project FROM overviews ORDER BY project');
const rows = stmt.all() as { project: string }[];
return rows.map(row => row.project);
}
/**
* Get most recent overview for a specific project
*/
getByProject(project: string): OverviewRow | null {
const stmt = this.db.prepare(`
SELECT * FROM overviews
WHERE project = ?
ORDER BY created_at_epoch DESC
LIMIT 1
`);
return stmt.get(project) as OverviewRow || null;
}
/**
* Create or update overview for a project (keeps only most recent)
*/
upsertByProject(input: OverviewInput): OverviewRow {
const existing = this.getByProject(input.project);
if (existing) {
return this.update(existing.id, input);
}
return this.create(input);
}
/**
* Delete overview by project name
*/
deleteByProject(project: string): boolean {
const stmt = this.db.prepare('DELETE FROM overviews WHERE project = ?');
const info = stmt.run(project);
return info.changes > 0;
}
}
+195
View File
@@ -0,0 +1,195 @@
import { Database } from 'better-sqlite3';
import { getDatabase } from './Database.js';
import { SessionRow, SessionInput, normalizeTimestamp } from './types.js';
/**
* Data Access Object for session records
*/
export class SessionStore {
private db: Database.Database;
constructor(db?: Database.Database) {
this.db = db || getDatabase();
}
/**
* Create a new session record
*/
create(input: SessionInput): SessionRow {
const { isoString, epoch } = normalizeTimestamp(input.created_at);
const stmt = this.db.prepare(`
INSERT INTO sessions (
session_id, project, created_at, created_at_epoch, source,
archive_path, archive_bytes, archive_checksum, archived_at, metadata_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const info = stmt.run(
input.session_id,
input.project,
isoString,
epoch,
input.source || 'compress',
input.archive_path || null,
input.archive_bytes || null,
input.archive_checksum || null,
input.archived_at || null,
input.metadata_json || null
);
return this.getById(info.lastInsertRowid as number)!;
}
/**
* Upsert a session record (insert or update if session_id exists)
*/
upsert(input: SessionInput): SessionRow {
const existing = this.getBySessionId(input.session_id);
if (existing) {
return this.update(existing.id, input);
}
return this.create(input);
}
/**
* Update an existing session record
*/
update(id: number, input: Partial<SessionInput>): SessionRow {
const existing = this.getById(id);
if (!existing) {
throw new Error(`Session with id ${id} not found`);
}
const { isoString, epoch } = normalizeTimestamp(input.created_at || existing.created_at);
const stmt = this.db.prepare(`
UPDATE sessions SET
project = ?, created_at = ?, created_at_epoch = ?, source = ?,
archive_path = ?, archive_bytes = ?, archive_checksum = ?, archived_at = ?, metadata_json = ?
WHERE id = ?
`);
stmt.run(
input.project || existing.project,
isoString,
epoch,
input.source || existing.source,
input.archive_path !== undefined ? input.archive_path : existing.archive_path,
input.archive_bytes !== undefined ? input.archive_bytes : existing.archive_bytes,
input.archive_checksum !== undefined ? input.archive_checksum : existing.archive_checksum,
input.archived_at !== undefined ? input.archived_at : existing.archived_at,
input.metadata_json !== undefined ? input.metadata_json : existing.metadata_json,
id
);
return this.getById(id)!;
}
/**
* Get session by primary key
*/
getById(id: number): SessionRow | null {
const stmt = this.db.prepare('SELECT * FROM sessions WHERE id = ?');
return stmt.get(id) as SessionRow || null;
}
/**
* Get session by session_id
*/
getBySessionId(sessionId: string): SessionRow | null {
const stmt = this.db.prepare('SELECT * FROM sessions WHERE session_id = ?');
return stmt.get(sessionId) as SessionRow || null;
}
/**
* Check if a session exists by session_id
*/
has(sessionId: string): boolean {
const stmt = this.db.prepare('SELECT 1 FROM sessions WHERE session_id = ? LIMIT 1');
return Boolean(stmt.get(sessionId));
}
/**
* Get all session_ids as a Set (useful for import-history)
*/
getAllSessionIds(): Set<string> {
const stmt = this.db.prepare('SELECT session_id FROM sessions');
const rows = stmt.all() as { session_id: string }[];
return new Set(rows.map(row => row.session_id));
}
/**
* Get recent sessions for a project
*/
getRecentForProject(project: string, limit = 5): SessionRow[] {
const stmt = this.db.prepare(`
SELECT * FROM sessions
WHERE project = ?
ORDER BY created_at_epoch DESC
LIMIT ?
`);
return stmt.all(project, limit) as SessionRow[];
}
/**
* Get recent sessions across all projects
*/
getRecent(limit = 5): SessionRow[] {
const stmt = this.db.prepare(`
SELECT * FROM sessions
ORDER BY created_at_epoch DESC
LIMIT ?
`);
return stmt.all(limit) as SessionRow[];
}
/**
* Get sessions by source type
*/
getBySource(source: 'compress' | 'save' | 'legacy-jsonl', limit?: number): SessionRow[] {
const query = limit
? 'SELECT * FROM sessions WHERE source = ? ORDER BY created_at_epoch DESC LIMIT ?'
: 'SELECT * FROM sessions WHERE source = ? ORDER BY created_at_epoch DESC';
const stmt = this.db.prepare(query);
const params = limit ? [source, limit] : [source];
return stmt.all(...params) as SessionRow[];
}
/**
* Count total sessions
*/
count(): number {
const stmt = this.db.prepare('SELECT COUNT(*) as count FROM sessions');
const result = stmt.get() as { count: number };
return result.count;
}
/**
* Count sessions by project
*/
countByProject(project: string): number {
const stmt = this.db.prepare('SELECT COUNT(*) as count FROM sessions WHERE project = ?');
const result = stmt.get(project) as { count: number };
return result.count;
}
/**
* Delete a session by ID (cascades to related records)
*/
deleteById(id: number): boolean {
const stmt = this.db.prepare('DELETE FROM sessions WHERE id = ?');
const info = stmt.run(id);
return info.changes > 0;
}
/**
* Delete a session by session_id (cascades to related records)
*/
deleteBySessionId(sessionId: string): boolean {
const stmt = this.db.prepare('DELETE FROM sessions WHERE session_id = ?');
const info = stmt.run(sessionId);
return info.changes > 0;
}
}
+107
View File
@@ -0,0 +1,107 @@
import { Database } from 'better-sqlite3';
import { getDatabase } from './Database.js';
import {
TranscriptEventInput,
TranscriptEventRow,
normalizeTimestamp
} from './types.js';
/**
* Data access for transcript_events table
*/
export class TranscriptEventStore {
private db: Database.Database;
constructor(db?: Database.Database) {
this.db = db || getDatabase();
}
/**
* Insert or update a transcript event
*/
upsert(event: TranscriptEventInput): TranscriptEventRow {
const { isoString, epoch } = normalizeTimestamp(event.captured_at);
const stmt = this.db.prepare(`
INSERT INTO transcript_events (
session_id,
project,
event_index,
event_type,
raw_json,
captured_at,
captured_at_epoch
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(session_id, event_index) DO UPDATE SET
project = excluded.project,
event_type = excluded.event_type,
raw_json = excluded.raw_json,
captured_at = excluded.captured_at,
captured_at_epoch = excluded.captured_at_epoch
`);
stmt.run(
event.session_id,
event.project || null,
event.event_index,
event.event_type || null,
event.raw_json,
isoString,
epoch
);
return this.getBySessionAndIndex(event.session_id, event.event_index)!;
}
/**
* Bulk upsert events in a single transaction
*/
upsertMany(events: TranscriptEventInput[]): TranscriptEventRow[] {
const transaction = this.db.transaction((rows: TranscriptEventInput[]) => {
const results: TranscriptEventRow[] = [];
for (const row of rows) {
results.push(this.upsert(row));
}
return results;
});
return transaction(events);
}
/**
* Get event by session and index
*/
getBySessionAndIndex(sessionId: string, eventIndex: number): TranscriptEventRow | null {
const stmt = this.db.prepare(`
SELECT * FROM transcript_events
WHERE session_id = ? AND event_index = ?
`);
return stmt.get(sessionId, eventIndex) as TranscriptEventRow | null;
}
/**
* Get highest event_index stored for a session
*/
getMaxEventIndex(sessionId: string): number {
const stmt = this.db.prepare(`
SELECT MAX(event_index) as max_event_index
FROM transcript_events
WHERE session_id = ?
`);
const row = stmt.get(sessionId) as { max_event_index: number | null } | undefined;
return row?.max_event_index ?? -1;
}
/**
* List recent events for a session
*/
listBySession(sessionId: string, limit = 200, offset = 0): TranscriptEventRow[] {
const stmt = this.db.prepare(`
SELECT * FROM transcript_events
WHERE session_id = ?
ORDER BY event_index ASC
LIMIT ? OFFSET ?
`);
return stmt.all(sessionId, limit, offset) as TranscriptEventRow[];
}
}
+43
View File
@@ -0,0 +1,43 @@
// Export main components
export { DatabaseManager, getDatabase, initializeDatabase } from './Database.js';
// Export store classes
export { SessionStore } from './SessionStore.js';
export { MemoryStore } from './MemoryStore.js';
export { OverviewStore } from './OverviewStore.js';
export { DiagnosticsStore } from './DiagnosticsStore.js';
export { TranscriptEventStore } from './TranscriptEventStore.js';
// Export types
export * from './types.js';
// Export migrations
export { migrations } from './migrations.js';
// Convenience function to get all stores
export async function createStores() {
const { DatabaseManager } = await import('./Database.js');
const { migrations } = await import('./migrations.js');
// Register migrations before initialization
const manager = DatabaseManager.getInstance();
for (const migration of migrations) {
manager.registerMigration(migration);
}
const db = await manager.initialize();
const { SessionStore } = await import('./SessionStore.js');
const { MemoryStore } = await import('./MemoryStore.js');
const { OverviewStore } = await import('./OverviewStore.js');
const { DiagnosticsStore } = await import('./DiagnosticsStore.js');
const { TranscriptEventStore } = await import('./TranscriptEventStore.js');
return {
sessions: new SessionStore(db),
memories: new MemoryStore(db),
overviews: new OverviewStore(db),
diagnostics: new DiagnosticsStore(db),
transcriptEvents: new TranscriptEventStore(db)
};
}
+169
View File
@@ -0,0 +1,169 @@
import { Database } from 'better-sqlite3';
import { Migration } from './Database.js';
/**
* Initial schema migration - creates all core tables
*/
export const migration001: Migration = {
version: 1,
up: (db: Database.Database) => {
// Sessions table - core session tracking
db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT UNIQUE NOT NULL,
project TEXT NOT NULL,
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
source TEXT NOT NULL DEFAULT 'compress',
archive_path TEXT,
archive_bytes INTEGER,
archive_checksum TEXT,
archived_at TEXT,
metadata_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project);
CREATE INDEX IF NOT EXISTS idx_sessions_created_at ON sessions(created_at_epoch DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_project_created ON sessions(project, created_at_epoch DESC);
`);
// Memories table - compressed memory chunks
db.exec(`
CREATE TABLE IF NOT EXISTS memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
text TEXT NOT NULL,
document_id TEXT UNIQUE,
keywords TEXT,
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
project TEXT NOT NULL,
archive_basename TEXT,
origin TEXT NOT NULL DEFAULT 'transcript',
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id);
CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project);
CREATE INDEX IF NOT EXISTS idx_memories_created_at ON memories(created_at_epoch DESC);
CREATE INDEX IF NOT EXISTS idx_memories_project_created ON memories(project, created_at_epoch DESC);
CREATE INDEX IF NOT EXISTS idx_memories_document_id ON memories(document_id);
CREATE INDEX IF NOT EXISTS idx_memories_origin ON memories(origin);
`);
// Overviews table - session summaries (one per project)
db.exec(`
CREATE TABLE IF NOT EXISTS overviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
project TEXT NOT NULL,
origin TEXT NOT NULL DEFAULT 'claude',
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_overviews_session ON overviews(session_id);
CREATE INDEX IF NOT EXISTS idx_overviews_project ON overviews(project);
CREATE INDEX IF NOT EXISTS idx_overviews_created_at ON overviews(created_at_epoch DESC);
CREATE INDEX IF NOT EXISTS idx_overviews_project_created ON overviews(project, created_at_epoch DESC);
CREATE UNIQUE INDEX IF NOT EXISTS idx_overviews_project_latest ON overviews(project, created_at_epoch DESC);
`);
// Diagnostics table - system health and debug info
db.exec(`
CREATE TABLE IF NOT EXISTS diagnostics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
message TEXT NOT NULL,
severity TEXT NOT NULL DEFAULT 'info',
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
project TEXT NOT NULL,
origin TEXT NOT NULL DEFAULT 'system',
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_diagnostics_session ON diagnostics(session_id);
CREATE INDEX IF NOT EXISTS idx_diagnostics_project ON diagnostics(project);
CREATE INDEX IF NOT EXISTS idx_diagnostics_severity ON diagnostics(severity);
CREATE INDEX IF NOT EXISTS idx_diagnostics_created ON diagnostics(created_at_epoch DESC);
`);
// Transcript events table - raw conversation events
db.exec(`
CREATE TABLE IF NOT EXISTS transcript_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
project TEXT,
event_index INTEGER NOT NULL,
event_type TEXT,
raw_json TEXT NOT NULL,
captured_at TEXT NOT NULL,
captured_at_epoch INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE,
UNIQUE(session_id, event_index)
);
CREATE INDEX IF NOT EXISTS idx_transcript_events_session ON transcript_events(session_id, event_index);
CREATE INDEX IF NOT EXISTS idx_transcript_events_project ON transcript_events(project);
CREATE INDEX IF NOT EXISTS idx_transcript_events_type ON transcript_events(event_type);
CREATE INDEX IF NOT EXISTS idx_transcript_events_captured ON transcript_events(captured_at_epoch DESC);
`);
console.log('✅ Created all database tables successfully');
},
down: (db: Database.Database) => {
db.exec(`
DROP TABLE IF EXISTS transcript_events;
DROP TABLE IF EXISTS diagnostics;
DROP TABLE IF EXISTS overviews;
DROP TABLE IF EXISTS memories;
DROP TABLE IF EXISTS sessions;
`);
}
};
/**
* Migration 002 - Add hierarchical memory fields (v2 format)
*/
export const migration002: Migration = {
version: 2,
up: (db: Database.Database) => {
// Add new columns for hierarchical memory structure
db.exec(`
ALTER TABLE memories ADD COLUMN title TEXT;
ALTER TABLE memories ADD COLUMN subtitle TEXT;
ALTER TABLE memories ADD COLUMN facts TEXT;
ALTER TABLE memories ADD COLUMN concepts TEXT;
ALTER TABLE memories ADD COLUMN files_touched TEXT;
`);
// Create indexes for the new fields to improve search performance
db.exec(`
CREATE INDEX IF NOT EXISTS idx_memories_title ON memories(title);
CREATE INDEX IF NOT EXISTS idx_memories_concepts ON memories(concepts);
`);
console.log('✅ Added hierarchical memory fields to memories table');
},
down: (db: Database.Database) => {
// Note: SQLite doesn't support DROP COLUMN in all versions
// In production, we'd need to recreate the table without these columns
// For now, we'll just log a warning
console.log('⚠️ Warning: SQLite ALTER TABLE DROP COLUMN not fully supported');
console.log('⚠️ To rollback, manually recreate the memories table');
}
};
/**
* All migrations in order
*/
export const migrations: Migration[] = [
migration001,
migration002
];
+184
View File
@@ -0,0 +1,184 @@
/**
* Database entity types for SQLite storage
*/
export interface SessionRow {
id: number;
session_id: string;
project: string;
created_at: string;
created_at_epoch: number;
source: 'compress' | 'save' | 'legacy-jsonl';
archive_path?: string;
archive_bytes?: number;
archive_checksum?: string;
archived_at?: string;
metadata_json?: string;
}
export interface OverviewRow {
id: number;
session_id: string;
content: string;
created_at: string;
created_at_epoch: number;
project: string;
origin: string;
}
export interface MemoryRow {
id: number;
session_id: string;
text: string;
document_id?: string;
keywords?: string;
created_at: string;
created_at_epoch: number;
project: string;
archive_basename?: string;
origin: string;
// Hierarchical memory fields (v2)
title?: string;
subtitle?: string;
facts?: string; // JSON array of fact strings
concepts?: string; // JSON array of concept strings
files_touched?: string; // JSON array of file paths
}
export interface DiagnosticRow {
id: number;
session_id?: string;
message: string;
severity: 'info' | 'warn' | 'error';
created_at: string;
created_at_epoch: number;
project: string;
origin: string;
}
export interface TranscriptEventRow {
id: number;
session_id: string;
project?: string;
event_index: number;
event_type?: string;
raw_json: string;
captured_at: string;
captured_at_epoch: number;
}
export interface ArchiveRow {
id: number;
session_id: string;
path: string;
bytes?: number;
checksum?: string;
stored_at: string;
storage_status: 'active' | 'archived' | 'deleted';
}
export interface TitleRow {
id: number;
session_id: string;
title: string;
created_at: string;
project: string;
}
/**
* Input types for creating new records (without id and auto-generated fields)
*/
export interface SessionInput {
session_id: string;
project: string;
created_at: string;
source?: 'compress' | 'save' | 'legacy-jsonl';
archive_path?: string;
archive_bytes?: number;
archive_checksum?: string;
archived_at?: string;
metadata_json?: string;
}
export interface OverviewInput {
session_id: string;
content: string;
created_at: string;
project: string;
origin?: string;
}
export interface MemoryInput {
session_id: string;
text: string;
document_id?: string;
keywords?: string;
created_at: string;
project: string;
archive_basename?: string;
origin?: string;
// Hierarchical memory fields (v2)
title?: string;
subtitle?: string;
facts?: string; // JSON array of fact strings
concepts?: string; // JSON array of concept strings
files_touched?: string; // JSON array of file paths
}
export interface DiagnosticInput {
session_id?: string;
message: string;
severity?: 'info' | 'warn' | 'error';
created_at: string;
project: string;
origin?: string;
}
export interface TranscriptEventInput {
session_id: string;
project?: string;
event_index: number;
event_type?: string;
raw_json: string;
captured_at?: string | Date | number;
}
/**
* Helper function to normalize timestamps from various formats
*/
export function normalizeTimestamp(timestamp: string | Date | number | undefined): { isoString: string; epoch: number } {
let date: Date;
if (!timestamp) {
date = new Date();
} else if (timestamp instanceof Date) {
date = timestamp;
} else if (typeof timestamp === 'number') {
date = new Date(timestamp);
} else if (typeof timestamp === 'string') {
// Handle empty strings
if (!timestamp.trim()) {
date = new Date();
} else {
date = new Date(timestamp);
// If invalid date, try to parse it differently
if (isNaN(date.getTime())) {
// Try common formats
const cleaned = timestamp.replace(/\s+/g, 'T').replace(/T+/g, 'T');
date = new Date(cleaned);
// Still invalid? Use current time
if (isNaN(date.getTime())) {
date = new Date();
}
}
}
} else {
date = new Date();
}
return {
isoString: date.toISOString(),
epoch: date.getTime()
};
}
+51
View File
@@ -0,0 +1,51 @@
import { readFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
// <Block> 5.1 ====================================
// Default values
const DEFAULT_PACKAGE_NAME = 'claude-mem';
// This MUST be replaced by build process with --define flag
// @ts-ignore
// For development, use fallback
const DEFAULT_PACKAGE_VERSION = typeof __DEFAULT_PACKAGE_VERSION__ !== 'undefined'
? __DEFAULT_PACKAGE_VERSION__
: '3.5.6-dev';
const DEFAULT_PACKAGE_DESCRIPTION = 'Memory compression system for Claude Code - persist context across sessions';
let packageName = DEFAULT_PACKAGE_NAME;
let packageVersion = DEFAULT_PACKAGE_VERSION;
let packageDescription = DEFAULT_PACKAGE_DESCRIPTION;
// </Block> =======================================
// Try to read package.json if it exists (for development)
// <Block> 5.2 ====================================
try {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const packageJsonPath = join(__dirname, '..', '..', 'package.json');
// <Block> 5.2a ====================================
if (existsSync(packageJsonPath)) {
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
// <Block> 5.2b ====================================
packageName = packageJson.name || DEFAULT_PACKAGE_NAME;
packageVersion = packageJson.version || DEFAULT_PACKAGE_VERSION;
packageDescription = packageJson.description || DEFAULT_PACKAGE_DESCRIPTION;
// </Block> =======================================
}
// </Block> =======================================
} catch {
// Use defaults if package.json can't be read
}
// </Block> =======================================
// <Block> 5.3 ====================================
// Export package configuration
export const PACKAGE_NAME = packageName;
export const PACKAGE_VERSION = packageVersion;
export const PACKAGE_DESCRIPTION = packageDescription;
// Export commonly used names
export const CLI_NAME = PACKAGE_NAME; // The CLI command name
// </Block> =======================================
+67
View File
@@ -0,0 +1,67 @@
/**
* Simple logging utility for claude-mem
*/
export interface LogLevel {
DEBUG: number;
INFO: number;
WARN: number;
ERROR: number;
}
const LOG_LEVELS: LogLevel = {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3,
};
class Logger {
// <Block> 2.1 ====================================
private level: number = LOG_LEVELS.INFO;
setLevel(level: keyof LogLevel): void {
this.level = LOG_LEVELS[level];
}
// </Block> =======================================
// <Block> 2.2 ====================================
debug(message: string, ...args: any[]): void {
if (this.level <= LOG_LEVELS.DEBUG) {
console.debug(`[DEBUG] ${message}`, ...args);
}
}
// </Block> =======================================
// <Block> 2.3 ====================================
info(message: string, ...args: any[]): void {
if (this.level <= LOG_LEVELS.INFO) {
console.info(`[INFO] ${message}`, ...args);
}
}
// </Block> =======================================
// <Block> 2.4 ====================================
warn(message: string, ...args: any[]): void {
if (this.level <= LOG_LEVELS.WARN) {
console.warn(`[WARN] ${message}`, ...args);
}
}
// </Block> =======================================
// <Block> 2.5 ====================================
error(message: string, error?: any, context?: any): void {
if (this.level <= LOG_LEVELS.ERROR) {
console.error(`[ERROR] ${message}`);
if (error) {
console.error(error);
}
if (context) {
console.error('Context:', context);
}
}
}
// </Block> =======================================
}
export const log = new Logger();
+91
View File
@@ -0,0 +1,91 @@
import { sep, basename } from 'path';
import { PathDiscovery } from '../services/path-discovery.js';
/**
* PathResolver utility for managing claude-mem file system paths
* Now delegates to PathDiscovery service for centralized path management
*/
export class PathResolver {
private pathDiscovery: PathDiscovery;
// <Block> 1.1 ====================================
constructor() {
this.pathDiscovery = PathDiscovery.getInstance();
}
// </Block> =======================================
// <Block> 1.2 ====================================
getConfigDir(): string {
return this.pathDiscovery.getDataDirectory();
}
// </Block> =======================================
// <Block> 1.3 ====================================
getIndexDir(): string {
return this.pathDiscovery.getIndexDirectory();
}
// </Block> =======================================
// <Block> 1.4 ====================================
getIndexPath(): string {
return this.pathDiscovery.getIndexPath();
}
// </Block> =======================================
// <Block> 1.5 ====================================
getArchiveDir(): string {
return this.pathDiscovery.getArchivesDirectory();
}
// </Block> =======================================
// <Block> 1.6 ====================================
getProjectArchiveDir(projectName: string): string {
return this.pathDiscovery.getProjectArchiveDirectory(projectName);
}
// </Block> =======================================
// <Block> 1.7 ====================================
getLogsDir(): string {
return this.pathDiscovery.getLogsDirectory();
}
// </Block> =======================================
// <Block> 1.8 ====================================
static ensureDirectory(dirPath: string): void {
PathDiscovery.getInstance().ensureDirectory(dirPath);
}
// </Block> =======================================
// <Block> 1.9 ====================================
static ensureDirectories(dirPaths: string[]): void {
PathDiscovery.getInstance().ensureDirectories(dirPaths);
}
// </Block> =======================================
// <Block> 1.10 ===================================
static extractProjectName(transcriptPath: string): string {
return PathDiscovery.extractProjectName(transcriptPath);
}
// <Block> 1.11 ===================================
/**
* DRY utility function: Canonical source for getting the current project prefix
* Replaces all instances of path.basename(process.cwd()) across the codebase
* @returns The current project directory name, sanitized for use as a prefix
*/
static getCurrentProjectPrefix(): string {
return PathDiscovery.getCurrentProjectName();
}
// </Block> =======================================
// <Block> 1.12 ===================================
/**
* DRY utility function: Gets raw project name without sanitization
* For use in contexts where original directory name is needed (e.g., display)
* @returns The current project directory name as-is
*/
static getCurrentProjectName(): string {
return PathDiscovery.getCurrentProjectName();
}
// </Block> =======================================
}
+42
View File
@@ -0,0 +1,42 @@
import { appendFileSync, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { PathDiscovery } from '../services/path-discovery.js';
let logPath: string | null = null;
function ensureLogPath(): string {
if (logPath) {
return logPath;
}
const discovery = PathDiscovery.getInstance();
const logsDir = discovery.getLogsDirectory();
if (!existsSync(logsDir)) {
mkdirSync(logsDir, { recursive: true });
}
logPath = join(logsDir, 'rolling-memory.log');
return logPath;
}
export type RollingLogLevel = 'debug' | 'info' | 'warn' | 'error';
export function rollingLog(
level: RollingLogLevel,
message: string,
payload: Record<string, unknown> = {}
): void {
try {
const file = ensureLogPath();
const entry = {
timestamp: new Date().toISOString(),
level,
message,
...payload
};
appendFileSync(file, `${JSON.stringify(entry)}\n`, 'utf8');
} catch {
// Logging should never throw user-facing errors
}
}
+87
View File
@@ -0,0 +1,87 @@
import { readSettings } from './settings.js';
export interface RollingSettings {
captureEnabled: boolean;
summaryEnabled: boolean;
sessionStartEnabled: boolean;
chunkTokenLimit: number;
chunkOverlapTokens: number;
summaryTurnLimit: number;
}
const DEFAULTS: RollingSettings = {
captureEnabled: true,
summaryEnabled: true,
sessionStartEnabled: true,
chunkTokenLimit: 600,
chunkOverlapTokens: 200,
summaryTurnLimit: 20
};
function normalizeBoolean(value: unknown, fallback: boolean): boolean {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string') {
const lowered = value.toLowerCase();
if (lowered === 'true') return true;
if (lowered === 'false') return false;
}
return fallback;
}
function normalizeNumber(value: unknown, fallback: number): number {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (!Number.isNaN(parsed) && Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
}
export function getRollingSettings(): RollingSettings {
const settings = readSettings();
return {
captureEnabled: normalizeBoolean(
settings.rollingCaptureEnabled,
DEFAULTS.captureEnabled
),
summaryEnabled: normalizeBoolean(
settings.rollingSummaryEnabled,
DEFAULTS.summaryEnabled
),
sessionStartEnabled: normalizeBoolean(
settings.rollingSessionStartEnabled,
DEFAULTS.sessionStartEnabled
),
chunkTokenLimit: normalizeNumber(
settings.rollingChunkTokens,
DEFAULTS.chunkTokenLimit
),
chunkOverlapTokens: normalizeNumber(
settings.rollingChunkOverlapTokens,
DEFAULTS.chunkOverlapTokens
),
summaryTurnLimit: normalizeNumber(
settings.rollingSummaryTurnLimit,
DEFAULTS.summaryTurnLimit
)
};
}
export function isRollingCaptureEnabled(): boolean {
return getRollingSettings().captureEnabled;
}
export function isRollingSummaryEnabled(): boolean {
return getRollingSettings().summaryEnabled;
}
export function isRollingSessionStartEnabled(): boolean {
return getRollingSettings().sessionStartEnabled;
}
+98
View File
@@ -0,0 +1,98 @@
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { PathResolver } from './paths.js';
import type { Settings } from './types.js';
/**
* Settings utilities for managing ~/.claude-mem/settings.json
*/
export class SettingsManager {
private static settingsPath: string;
private static cachedSettings: Settings | null = null;
static {
const pathResolver = new PathResolver();
this.settingsPath = join(pathResolver.getConfigDir(), 'settings.json');
}
/**
* Safely read settings.json with error handling
* Returns empty object if file doesn't exist or is malformed
*/
static readSettings(): Settings {
// Return cached settings if available
if (this.cachedSettings !== null) {
return this.cachedSettings;
}
try {
if (existsSync(this.settingsPath)) {
const content = readFileSync(this.settingsPath, 'utf-8');
const settings = JSON.parse(content) as Settings;
this.cachedSettings = settings;
return settings;
}
} catch {
// File is malformed or unreadable - return empty settings
}
// File doesn't exist or failed to read
const emptySettings: Settings = {};
this.cachedSettings = emptySettings;
return emptySettings;
}
/**
* Get a specific setting value with optional fallback
*/
static getSetting<K extends keyof Settings>(
key: K,
fallback?: Settings[K]
): Settings[K] | undefined {
const settings = this.readSettings();
return settings[key] ?? fallback;
}
/**
* Get the Claude binary path from settings
* Falls back to 'claude' if not found or settings don't exist
*/
static getClaudePath(): string {
const claudePath = this.getSetting('claudePath', 'claude');
return claudePath as string;
}
/**
* Clear cached settings (useful for testing or after settings changes)
*/
static clearCache(): void {
this.cachedSettings = null;
}
}
/**
* Convenience function to get Claude binary path
* Can be imported directly for simple use cases
*/
export function getClaudePath(): string {
return SettingsManager.getClaudePath();
}
/**
* Convenience function to read all settings
* Can be imported directly for simple use cases
*/
export function readSettings(): Settings {
return SettingsManager.readSettings();
}
/**
* Convenience function to get a specific setting
* Can be imported directly for simple use cases
*/
export function getSetting<K extends keyof Settings>(
key: K,
fallback?: Settings[K]
): Settings[K] | undefined {
return SettingsManager.getSetting(key, fallback);
}
+402
View File
@@ -0,0 +1,402 @@
import fs from 'fs';
import { PathDiscovery } from '../services/path-discovery.js';
import {
createStores,
SessionStore,
MemoryStore,
OverviewStore,
DiagnosticsStore,
SessionInput,
MemoryInput,
OverviewInput,
DiagnosticInput,
SessionRow,
MemoryRow,
OverviewRow,
DiagnosticRow,
normalizeTimestamp
} from '../services/sqlite/index.js';
/**
* Storage backend types
*/
export type StorageBackend = 'sqlite' | 'jsonl';
/**
* Unified interface for storage operations
*/
export interface IStorageProvider {
backend: StorageBackend;
// Session operations
createSession(session: SessionInput): Promise<SessionRow | void>;
getSession(sessionId: string): Promise<SessionRow | null>;
hasSession(sessionId: string): Promise<boolean>;
getAllSessionIds(): Promise<Set<string>>;
getRecentSessions(limit?: number): Promise<SessionRow[]>;
getRecentSessionsForProject(project: string, limit?: number): Promise<SessionRow[]>;
// Memory operations
createMemory(memory: MemoryInput): Promise<MemoryRow | void>;
createMemories(memories: MemoryInput[]): Promise<void>;
getRecentMemories(limit?: number): Promise<MemoryRow[]>;
getRecentMemoriesForProject(project: string, limit?: number): Promise<MemoryRow[]>;
hasDocumentId(documentId: string): Promise<boolean>;
// Overview operations
createOverview(overview: OverviewInput): Promise<OverviewRow | void>;
upsertOverview(overview: OverviewInput): Promise<OverviewRow | void>;
getRecentOverviews(limit?: number): Promise<OverviewRow[]>;
getRecentOverviewsForProject(project: string, limit?: number): Promise<OverviewRow[]>;
// Diagnostic operations
createDiagnostic(diagnostic: DiagnosticInput): Promise<DiagnosticRow | void>;
// Health check
isAvailable(): Promise<boolean>;
}
/**
* SQLite-based storage provider
*/
export class SQLiteStorageProvider implements IStorageProvider {
public readonly backend = 'sqlite';
private stores?: {
sessions: SessionStore;
memories: MemoryStore;
overviews: OverviewStore;
diagnostics: DiagnosticsStore;
};
private async getStores() {
if (!this.stores) {
this.stores = await createStores();
}
return this.stores;
}
async isAvailable(): Promise<boolean> {
try {
await this.getStores();
return true;
} catch (error) {
return false;
}
}
async createSession(session: SessionInput): Promise<SessionRow> {
const stores = await this.getStores();
return stores.sessions.create(session);
}
async getSession(sessionId: string): Promise<SessionRow | null> {
const stores = await this.getStores();
return stores.sessions.getBySessionId(sessionId);
}
async hasSession(sessionId: string): Promise<boolean> {
const stores = await this.getStores();
return stores.sessions.has(sessionId);
}
async getAllSessionIds(): Promise<Set<string>> {
const stores = await this.getStores();
return stores.sessions.getAllSessionIds();
}
async getRecentSessions(limit = 5): Promise<SessionRow[]> {
const stores = await this.getStores();
return stores.sessions.getRecent(limit);
}
async getRecentSessionsForProject(project: string, limit = 5): Promise<SessionRow[]> {
const stores = await this.getStores();
return stores.sessions.getRecentForProject(project, limit);
}
async createMemory(memory: MemoryInput): Promise<MemoryRow> {
const stores = await this.getStores();
return stores.memories.create(memory);
}
async createMemories(memories: MemoryInput[]): Promise<void> {
const stores = await this.getStores();
stores.memories.createMany(memories);
}
async getRecentMemories(limit = 10): Promise<MemoryRow[]> {
const stores = await this.getStores();
return stores.memories.getRecent(limit);
}
async getRecentMemoriesForProject(project: string, limit = 10): Promise<MemoryRow[]> {
const stores = await this.getStores();
return stores.memories.getRecentForProject(project, limit);
}
async hasDocumentId(documentId: string): Promise<boolean> {
const stores = await this.getStores();
return stores.memories.hasDocumentId(documentId);
}
async createOverview(overview: OverviewInput): Promise<OverviewRow> {
const stores = await this.getStores();
return stores.overviews.create(overview);
}
async upsertOverview(overview: OverviewInput): Promise<OverviewRow> {
const stores = await this.getStores();
return stores.overviews.upsert(overview);
}
async getRecentOverviews(limit = 5): Promise<OverviewRow[]> {
const stores = await this.getStores();
return stores.overviews.getRecent(limit);
}
async getRecentOverviewsForProject(project: string, limit = 5): Promise<OverviewRow[]> {
const stores = await this.getStores();
return stores.overviews.getRecentForProject(project, limit);
}
async createDiagnostic(diagnostic: DiagnosticInput): Promise<DiagnosticRow> {
const stores = await this.getStores();
return stores.diagnostics.create(diagnostic);
}
}
/**
* JSONL-based storage provider (legacy fallback)
*/
export class JSONLStorageProvider implements IStorageProvider {
public readonly backend = 'jsonl';
private pathDiscovery = PathDiscovery.getInstance();
async isAvailable(): Promise<boolean> {
try {
// Ensure data directory exists
const dataDir = this.pathDiscovery.getDataDirectory();
fs.mkdirSync(dataDir, { recursive: true });
return true;
} catch {
return false;
}
}
private appendToIndex(obj: any): void {
const indexPath = this.pathDiscovery.getIndexPath();
fs.appendFileSync(indexPath, JSON.stringify(obj) + '\\n', 'utf8');
}
async createSession(session: SessionInput): Promise<void> {
const sessionRecord = {
type: 'session',
session_id: session.session_id,
project: session.project,
timestamp: session.created_at
};
this.appendToIndex(sessionRecord);
}
async getSession(): Promise<null> {
// Not supported in JSONL mode
return null;
}
async hasSession(sessionId: string): Promise<boolean> {
const sessionIds = await this.getAllSessionIds();
return sessionIds.has(sessionId);
}
async getAllSessionIds(): Promise<Set<string>> {
const indexPath = this.pathDiscovery.getIndexPath();
if (!fs.existsSync(indexPath)) {
return new Set();
}
const content = fs.readFileSync(indexPath, 'utf-8');
const lines = content.trim().split('\\n').filter(line => line.trim());
const sessionIds = new Set<string>();
for (const line of lines) {
try {
const obj = JSON.parse(line);
if (obj.session_id) {
sessionIds.add(obj.session_id);
}
} catch {
// Skip malformed JSON
}
}
return sessionIds;
}
async getRecentSessions(): Promise<SessionRow[]> {
// Not fully supported in JSONL mode - return empty array
return [];
}
async getRecentSessionsForProject(): Promise<SessionRow[]> {
// Not fully supported in JSONL mode - return empty array
return [];
}
async createMemory(memory: MemoryInput): Promise<void> {
const memoryRecord = {
type: 'memory',
text: memory.text,
document_id: memory.document_id,
keywords: memory.keywords,
session_id: memory.session_id,
project: memory.project,
timestamp: memory.created_at,
archive: memory.archive_basename
};
this.appendToIndex(memoryRecord);
}
async createMemories(memories: MemoryInput[]): Promise<void> {
for (const memory of memories) {
await this.createMemory(memory);
}
}
async getRecentMemories(): Promise<MemoryRow[]> {
// Not fully supported in JSONL mode - return empty array
return [];
}
async getRecentMemoriesForProject(): Promise<MemoryRow[]> {
// Not fully supported in JSONL mode - return empty array
return [];
}
async hasDocumentId(documentId: string): Promise<boolean> {
const indexPath = this.pathDiscovery.getIndexPath();
if (!fs.existsSync(indexPath)) {
return false;
}
const content = fs.readFileSync(indexPath, 'utf-8');
const lines = content.trim().split('\\n').filter(line => line.trim());
for (const line of lines) {
try {
const obj = JSON.parse(line);
if (obj.type === 'memory' && obj.document_id === documentId) {
return true;
}
} catch {
// Skip malformed JSON
}
}
return false;
}
async createOverview(overview: OverviewInput): Promise<void> {
const overviewRecord = {
type: 'overview',
content: overview.content,
session_id: overview.session_id,
project: overview.project,
timestamp: overview.created_at
};
this.appendToIndex(overviewRecord);
}
async upsertOverview(overview: OverviewInput): Promise<void> {
// Just append in JSONL mode (no real upsert)
await this.createOverview(overview);
}
async getRecentOverviews(): Promise<OverviewRow[]> {
// Not fully supported in JSONL mode - return empty array
return [];
}
async getRecentOverviewsForProject(): Promise<OverviewRow[]> {
// Not fully supported in JSONL mode - return empty array
return [];
}
async createDiagnostic(diagnostic: DiagnosticInput): Promise<void> {
const diagnosticRecord = {
type: 'diagnostic',
message: diagnostic.message,
session_id: diagnostic.session_id,
project: diagnostic.project,
timestamp: diagnostic.created_at
};
this.appendToIndex(diagnosticRecord);
}
}
/**
* Storage provider factory and singleton
*/
let storageProvider: IStorageProvider | null = null;
/**
* Get the configured storage provider
*/
export async function getStorageProvider(): Promise<IStorageProvider> {
if (storageProvider) {
return storageProvider;
}
// Try SQLite first
const sqliteProvider = new SQLiteStorageProvider();
if (await sqliteProvider.isAvailable()) {
storageProvider = sqliteProvider;
return storageProvider;
}
// Fall back to JSONL
const jsonlProvider = new JSONLStorageProvider();
if (await jsonlProvider.isAvailable()) {
storageProvider = jsonlProvider;
return storageProvider;
}
throw new Error('No storage backend available');
}
/**
* Force a specific storage provider (useful for testing)
*/
export function setStorageProvider(provider: IStorageProvider): void {
storageProvider = provider;
}
/**
* Check if SQLite migration is needed
*/
export async function needsMigration(): Promise<boolean> {
const pathDiscovery = PathDiscovery.getInstance();
const indexPath = pathDiscovery.getIndexPath();
// If JSONL exists but SQLite is not available, migration is needed
if (fs.existsSync(indexPath)) {
const sqliteProvider = new SQLiteStorageProvider();
const sqliteAvailable = await sqliteProvider.isAvailable();
if (!sqliteAvailable) {
return true;
}
// Check if SQLite has data
try {
const stores = await createStores();
const sessionCount = stores.sessions.count();
return sessionCount === 0; // Needs migration if SQLite is empty
} catch {
return true;
}
}
return false;
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Core Type Definitions
*
* Minimal type definitions for the claude-mem system.
* Only includes types that are actively imported and used.
*/
// =============================================================================
// ERROR CLASSES
// =============================================================================
/**
* Custom error class for compression failures
*/
export class CompressionError extends Error {
constructor(
message: string,
public transcriptPath: string,
public stage: 'reading' | 'analyzing' | 'compressing' | 'writing'
) {
super(message);
this.name = 'CompressionError';
}
}
// =============================================================================
// CONFIGURATION TYPES
// =============================================================================
/**
* Main settings interface for claude-mem configuration
*/
export interface Settings {
autoCompress?: boolean;
projectName?: string;
installed?: boolean;
backend?: string;
embedded?: boolean;
saveMemoriesOnClear?: boolean;
claudePath?: string;
rollingCaptureEnabled?: boolean;
rollingSummaryEnabled?: boolean;
rollingSessionStartEnabled?: boolean;
rollingChunkTokens?: number;
rollingChunkOverlapTokens?: number;
rollingSummaryTurnLimit?: number;
[key: string]: unknown; // Allow additional properties
}
+819
View File
@@ -0,0 +1,819 @@
import { useState, useEffect, useRef } from 'react';
import {
Dialog,
DialogBackdrop,
DialogPanel,
TransitionChild,
} from '@headlessui/react';
import {
Bars3Icon,
MagnifyingGlassIcon,
XMarkIcon,
} from '@heroicons/react/20/solid';
import OverviewCard from './src/components/OverviewCard';
function classNames(...classes) {
return classes.filter(Boolean).join(' ');
}
export default function MemoryStream() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [overviewsOpen, setOverviewsOpen] = useState(false);
const [memories, setMemories] = useState([]);
const [overviews, setOverviews] = useState([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [status, setStatus] = useState('connecting');
const [connected, setConnected] = useState(false);
const [selectedProject, setSelectedProject] = useState('all');
const [selectedTag, setSelectedTag] = useState(null);
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [isAwaitingOverview, setIsAwaitingOverview] = useState(false);
const [debugOverviewCard, setDebugOverviewCard] = useState(false);
const eventSourceRef = useRef(null);
let filteredMemories = selectedProject === 'all'
? memories
: memories.filter(m => m.project === selectedProject);
if (selectedTag) {
filteredMemories = filteredMemories.filter(m => m.concepts?.includes(selectedTag));
}
const filteredOverviews = selectedProject === 'all'
? overviews
: overviews.filter(o => o.project === selectedProject);
const existingCount = filteredMemories.filter(m => !m.isNew).length;
const newCount = filteredMemories.filter(m => m.isNew).length;
const stats = {
total: filteredMemories.length,
new: newCount,
existing: existingCount,
sessions: new Set(filteredMemories.map(m => m.session_id)).size,
projects: new Set(memories.map(m => m.project)).size
};
const projects = ['all', ...new Set(memories.map(m => m.project).filter(Boolean))];
useEffect(() => {
setStatus('connecting');
const eventSource = new EventSource('http://localhost:3001/stream');
eventSourceRef.current = eventSource;
eventSource.onopen = () => {
setStatus('connected');
setConnected(true);
};
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'initial_load') {
const existingMemories = data.memories.map(m => ({ ...m, isNew: false }));
setMemories(existingMemories);
const existingOverviews = data.overviews.map(o => ({ ...o, isNew: false }));
setOverviews(existingOverviews);
setInitialLoadComplete(true);
setCurrentIndex(0);
} else if (data.type === 'new_memories') {
const newMemories = data.memories.map(m => ({ ...m, isNew: true }));
setMemories(prev => [...newMemories, ...prev]);
setCurrentIndex(0);
} else if (data.type === 'new_overviews') {
const newOverviews = data.overviews.map(o => ({ ...o, isNew: true }));
// Remove placeholders for the same projects as the incoming real overviews
const incomingProjects = new Set(newOverviews.map(o => o.project));
setOverviews(prev => {
const withoutPlaceholders = prev.filter(o =>
!o.isPlaceholder || !incomingProjects.has(o.project)
);
return [...newOverviews, ...withoutPlaceholders];
});
setIsAwaitingOverview(false);
} else if (data.type === 'session_start') {
// Only process for current project (or 'all')
if (selectedProject === 'all' || data.project === selectedProject) {
setIsProcessing(true);
setIsAwaitingOverview(true);
// Create placeholder overview card
const placeholderOverview = {
id: `placeholder-${Date.now()}`,
project: data.project,
content: '⏳ Session in progress...',
created_at: new Date().toISOString(),
session_id: null,
isNew: true,
isPlaceholder: true
};
setOverviews(prev => [placeholderOverview, ...prev]);
}
} else if (data.type === 'session_end') {
// Only process for current project (or 'all')
if (selectedProject === 'all' || data.project === selectedProject) {
setIsProcessing(false);
setIsAwaitingOverview(false);
}
}
};
eventSource.onerror = () => {
setStatus('reconnecting');
setConnected(false);
eventSource.close();
setTimeout(() => window.location.reload(), 2000);
};
return () => eventSource.close();
}, []);
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'ArrowLeft') {
e.preventDefault();
setCurrentIndex(i => (i - 1 + filteredMemories.length) % filteredMemories.length);
} else if (e.key === 'ArrowRight') {
e.preventDefault();
setCurrentIndex(i => (i + 1) % filteredMemories.length);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [filteredMemories.length]);
const formatTimestamp = (timestamp) => {
const date = new Date(timestamp);
const diff = Date.now() - date;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (seconds < 60) return `${seconds}s ago`;
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
};
const memory = filteredMemories[currentIndex] || {};
// Extract unique tags from all memories
const allTags = [...new Set(memories.flatMap(m => m.concepts || []))];
const tagCounts = allTags.reduce((acc, tag) => {
acc[tag] = memories.filter(m => m.concepts?.includes(tag)).length;
return acc;
}, {});
const sortedTags = allTags.sort((a, b) => tagCounts[b] - tagCounts[a]);
return (
<>
<div className="min-h-screen bg-black text-gray-100 relative overflow-hidden">
{/* Background Effects */}
<div className="fixed inset-0 opacity-20">
<div className="absolute inset-0" style={{
backgroundImage: 'linear-gradient(rgba(59, 130, 246, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(59, 130, 246, 0.1) 1px, transparent 1px)',
backgroundSize: '50px 50px'
}} />
</div>
<div className="fixed inset-0 pointer-events-none">
<div className="absolute top-0 left-0 w-full h-full" style={{
background: 'radial-gradient(ellipse at 20% 30%, rgba(59, 130, 246, 0.15) 0%, transparent 50%)'
}} />
<div className="absolute top-0 right-0 w-full h-full" style={{
background: 'radial-gradient(ellipse at 80% 20%, rgba(139, 92, 246, 0.15) 0%, transparent 50%)'
}} />
<div className="absolute bottom-0 left-1/2 w-full h-full" style={{
background: 'radial-gradient(ellipse at 50% 80%, rgba(16, 185, 129, 0.1) 0%, transparent 50%)'
}} />
</div>
{/* Mobile sidebar */}
<Dialog open={sidebarOpen} onClose={setSidebarOpen} className="relative z-50 xl:hidden">
<DialogBackdrop
transition
className="fixed inset-0 bg-gray-900/80 transition-opacity duration-300 ease-linear data-[closed]:opacity-0"
/>
<div className="fixed inset-0 flex">
<DialogPanel
transition
className="relative mr-16 flex w-full max-w-xs flex-1 transform transition duration-300 ease-in-out data-[closed]:-translate-x-full"
>
<TransitionChild>
<div className="absolute left-full top-0 flex w-16 justify-center pt-5 duration-300 ease-in-out data-[closed]:opacity-0">
<button type="button" onClick={() => setSidebarOpen(false)} className="-m-2.5 p-2.5">
<span className="sr-only">Close sidebar</span>
<XMarkIcon aria-hidden="true" className="size-6 text-white" />
</button>
</div>
</TransitionChild>
<div className="relative flex grow flex-col gap-y-5 overflow-y-auto bg-gray-900/90 backdrop-blur-xl px-6 border-r border-gray-800">
<div className="relative flex h-16 shrink-0 items-center">
<img src="/claude-mem-logo.webp" alt="claude-mem" className="h-10 w-auto" />
</div>
<nav className="relative flex flex-1 flex-col">
<div className="space-y-6">
<div className="relative">
<div className="absolute -inset-2 bg-gradient-to-r from-blue-600/10 via-purple-600/10 to-emerald-600/10 rounded-xl blur-xl" />
<div className="relative bg-gray-800/50 backdrop-blur-sm rounded-xl p-4 border border-gray-700/50">
<h3 className="text-xs font-bold text-blue-400 mb-3 flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-blue-400" />
STATISTICS
</h3>
<div className="space-y-2.5">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-400">Total</span>
<span className="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-300 to-blue-500">{stats.total}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-400">New</span>
<span className="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-emerald-300 to-emerald-500">{stats.new}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-400">Sessions</span>
<span className="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-300 to-purple-500">{stats.sessions}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-400">Projects</span>
<span className="text-lg font-bold text-gray-300">{stats.projects}</span>
</div>
</div>
</div>
</div>
<div className="relative">
<div className="absolute -inset-2 bg-gradient-to-r from-purple-600/10 via-blue-600/10 to-purple-600/10 rounded-xl blur-xl" />
<div className="relative bg-gray-800/50 backdrop-blur-sm rounded-xl p-4 border border-gray-700/50">
<h3 className="text-xs font-bold text-purple-400 mb-3 flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-purple-400" />
TAG CLOUD
</h3>
<div className="flex flex-wrap gap-2">
{sortedTags.slice(0, 20).map((tag) => (
<span
key={tag}
onClick={() => {
setSelectedTag(selectedTag === tag ? null : tag);
setCurrentIndex(0);
}}
className={classNames(
"px-2.5 py-1 rounded-lg border text-xs font-medium transition-all cursor-pointer",
selectedTag === tag
? "bg-purple-500/30 border-purple-400/60 text-purple-200 shadow-lg shadow-purple-500/20"
: "bg-purple-500/10 border-purple-400/30 text-purple-300 hover:bg-purple-500/20"
)}
>
{tag} ({tagCounts[tag]})
</span>
))}
</div>
</div>
</div>
</div>
</nav>
</div>
</DialogPanel>
</div>
</Dialog>
{/* Desktop sidebar */}
<div className="hidden xl:fixed xl:inset-y-0 xl:z-50 xl:flex xl:w-80 xl:flex-col">
<div className="relative flex grow flex-col gap-y-5 overflow-y-auto bg-gray-900/90 backdrop-blur-xl px-6 border-r border-gray-800">
<div className="flex h-16 shrink-0 items-center">
<img src="/claude-mem-logo.webp" alt="claude-mem" className="h-10 w-auto" />
</div>
<nav className="flex flex-1 flex-col">
<div className="space-y-6">
<div className="relative">
<div className="absolute -inset-2 bg-gradient-to-r from-blue-600/10 via-purple-600/10 to-emerald-600/10 rounded-xl blur-xl" />
<div className="relative bg-gray-800/50 backdrop-blur-sm rounded-xl p-4 border border-gray-700/50">
<h3 className="text-xs font-bold text-blue-400 mb-3 flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-blue-400" />
STATISTICS
</h3>
<div className="space-y-2.5">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-400">Total</span>
<span className="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-300 to-blue-500">{stats.total}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-400">New</span>
<span className="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-emerald-300 to-emerald-500">{stats.new}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-400">Sessions</span>
<span className="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-300 to-purple-500">{stats.sessions}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-400">Projects</span>
<span className="text-lg font-bold text-gray-300">{stats.projects}</span>
</div>
</div>
</div>
</div>
<div className="relative">
<div className="absolute -inset-2 bg-gradient-to-r from-purple-600/10 via-blue-600/10 to-purple-600/10 rounded-xl blur-xl" />
<div className="relative bg-gray-800/50 backdrop-blur-sm rounded-xl p-4 border border-gray-700/50">
<h3 className="text-xs font-bold text-purple-400 mb-3 flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-purple-400" />
TAG CLOUD
</h3>
<div className="flex flex-wrap gap-2">
{sortedTags.slice(0, 20).map((tag) => (
<span
key={tag}
onClick={() => {
setSelectedTag(selectedTag === tag ? null : tag);
setCurrentIndex(0);
}}
className={classNames(
"px-2.5 py-1 rounded-lg border text-xs font-medium transition-all cursor-pointer",
selectedTag === tag
? "bg-purple-500/30 border-purple-400/60 text-purple-200 shadow-lg shadow-purple-500/20"
: "bg-purple-500/10 border-purple-400/30 text-purple-300 hover:bg-purple-500/20"
)}
>
{tag} ({tagCounts[tag]})
</span>
))}
</div>
</div>
</div>
</div>
</nav>
</div>
</div>
<div className="xl:pl-80">
{/* Fixed search header */}
<div className="fixed top-0 left-0 right-0 xl:left-80 z-40 flex h-16 shrink-0 items-center gap-x-6 border-b border-gray-800 bg-gray-900/90 backdrop-blur-xl px-4 sm:px-6 lg:px-8">
<button
type="button"
onClick={() => setSidebarOpen(true)}
className="-m-2.5 p-2.5 text-gray-300 xl:hidden hover:text-white transition-colors"
>
<span className="sr-only">Open sidebar</span>
<Bars3Icon aria-hidden="true" className="size-5" />
</button>
<div className="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
<form action="#" method="GET" className="grid flex-1 grid-cols-1 relative">
<input
name="search"
placeholder="Search memories..."
aria-label="Search"
className="col-start-1 row-start-1 block size-full bg-gray-800/50 rounded-lg pl-10 pr-4 text-base text-gray-100 border border-gray-700 focus:border-blue-500/50 outline-none placeholder:text-gray-500 sm:text-sm/6 transition-colors"
/>
<MagnifyingGlassIcon
aria-hidden="true"
className="pointer-events-none col-start-1 row-start-1 size-5 self-center ml-3 text-gray-500"
/>
</form>
</div>
<div className="flex items-center gap-3">
{connected && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-gradient-to-r from-purple-500/20 to-blue-500/20 border border-purple-400/30">
<div className="w-2 h-2 bg-purple-400 rounded-full animate-pulse shadow-lg shadow-purple-400/50" />
<span className="text-xs font-bold text-purple-300 tracking-wide">LIVE</span>
</div>
)}
<button
onClick={() => setDebugOverviewCard(!debugOverviewCard)}
className={`px-3 py-1.5 rounded-full text-xs font-bold transition-all ${
debugOverviewCard
? 'bg-gradient-to-r from-blue-500/30 to-purple-500/30 border border-blue-400/60 text-blue-300'
: 'bg-gray-800/50 border border-gray-700 text-gray-400 hover:border-gray-600'
}`}
>
DEBUG
</button>
</div>
<button
type="button"
onClick={() => setOverviewsOpen(true)}
className="-m-2.5 p-2.5 text-gray-300 xl:hidden hover:text-white transition-colors"
>
<span className="sr-only">Open overviews</span>
<Bars3Icon aria-hidden="true" className="size-5" />
</button>
</div>
<main className="pt-16">
{/* Activity Indicator Bar */}
<div className="h-1 fixed top-16 left-0 right-0 xl:left-80 z-30" style={{
background: 'linear-gradient(90deg, transparent, #3b82f6, #8b5cf6, #10b981, transparent)',
animation: isProcessing ? 'scan 3s ease-in-out infinite' : 'none',
opacity: isProcessing ? 1 : 0,
boxShadow: isProcessing ? '0 0 20px rgba(59, 130, 246, 0.8)' : 'none'
}} />
{/* Debug Overview Card Mode */}
{debugOverviewCard && (
<OverviewCard debugMode={true} initialState="empty" />
)}
{/* Normal Memory Stream View */}
{!debugOverviewCard && (
<div className="px-4 sm:px-6 lg:px-8 py-6">
{!connected && (
<div className="max-w-3xl mx-auto mb-12">
<div className="relative group">
<div className="absolute -inset-1 bg-gradient-to-r from-blue-600 via-purple-600 to-emerald-600 rounded-2xl blur opacity-25 animate-pulse" />
<div className="relative bg-gray-900/90 backdrop-blur-xl rounded-2xl p-8 border border-gray-800">
<div className="text-center">
<div className="relative inline-block mb-4">
<div className="absolute inset-0 bg-blue-500/20 blur-3xl animate-pulse" />
<div className="relative text-6xl">📡</div>
</div>
<h2 className="text-2xl font-bold mb-2 bg-gradient-to-r from-blue-300 to-purple-300 bg-clip-text text-transparent">
{status === 'connecting' ? 'Connecting to Memory Stream' : 'Reconnecting...'}
</h2>
<p className="text-gray-400">~/.claude-mem/claude-mem.db</p>
</div>
</div>
</div>
</div>
)}
{connected && filteredMemories.length === 0 && (
<div className="max-w-4xl mx-auto text-center py-20">
<div className="relative inline-block">
<div className="absolute inset-0 bg-purple-500/20 blur-3xl animate-pulse" />
<div className="relative text-6xl mb-4">💭</div>
</div>
<h3 className="text-2xl font-bold text-gray-300 mb-2">No Memories Found</h3>
<p className="text-gray-500">
{selectedProject === 'all'
? 'No memories with titles in database'
: `No memories for project: ${selectedProject}`}
</p>
</div>
)}
{filteredMemories.length > 0 && (
<div className="mb-8 max-w-6xl mx-auto relative z-50">
<div className="flex items-center gap-4">
<select
value={selectedProject}
onChange={(e) => {
setSelectedProject(e.target.value);
setCurrentIndex(0);
}}
className="px-4 py-2 rounded-lg bg-gray-800/50 border border-gray-700 text-gray-300 font-mono text-sm cursor-pointer hover:border-gray-600 focus:outline-none focus:border-blue-500/50 transition-colors"
>
{projects.map(project => (
<option key={project} value={project}>
{project === 'all' ? 'All Projects' : project}
</option>
))}
</select>
<button
onClick={() => setCurrentIndex(i => (i - 1 + filteredMemories.length) % filteredMemories.length)}
className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-600/20 to-purple-600/20 border border-blue-400/30 hover:border-blue-400/60 flex items-center justify-center transition-all duration-300 hover:scale-110 group"
>
<span className="text-blue-300 text-lg group-hover:text-blue-200"></span>
</button>
<div className="flex items-center gap-3 flex-1">
<div className="flex-1 h-1.5 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-blue-500 via-purple-500 to-emerald-500 transition-all duration-300"
style={{ width: `${((currentIndex + 1) / filteredMemories.length) * 100}%` }}
/>
</div>
<div className="text-sm font-mono text-gray-500 min-w-[80px] text-center">
{currentIndex + 1} / {filteredMemories.length}
</div>
</div>
<button
onClick={() => setCurrentIndex(i => (i + 1) % filteredMemories.length)}
className="w-10 h-10 rounded-full bg-gradient-to-br from-purple-600/20 to-blue-600/20 border border-purple-400/30 hover:border-purple-400/60 flex items-center justify-center transition-all duration-300 hover:scale-110 group"
>
<span className="text-purple-300 text-lg group-hover:text-purple-200"></span>
</button>
</div>
</div>
)}
{filteredMemories.length > 0 && (
<div className="max-w-6xl mx-auto">
<div key={memory.id} className="relative" style={{
animation: 'slideIn 0.6s cubic-bezier(0.34, 1.56, 0.64, 1)'
}}>
<div className="absolute -inset-4 bg-gradient-to-r from-blue-600/20 via-purple-600/20 to-emerald-600/20 rounded-3xl blur-2xl" />
<div className="relative bg-gradient-to-br from-gray-900/90 to-gray-950/90 backdrop-blur-xl rounded-3xl p-12 border border-gray-800">
<div className="mb-8">
<div className="flex items-center gap-3 mb-4 flex-wrap">
<span className="px-4 py-1.5 rounded-full text-xs font-bold bg-gradient-to-r from-blue-500/20 to-blue-500/10 border border-blue-400/30 text-blue-300">
#{memory.id}
</span>
<span className="px-4 py-1.5 rounded-full text-xs font-bold bg-gradient-to-r from-purple-500/20 to-purple-500/10 border border-purple-400/30 text-purple-300">
{memory.project}
</span>
{memory.origin && (
<span className="px-4 py-1.5 rounded-full text-xs font-bold bg-gradient-to-r from-emerald-500/20 to-emerald-500/10 border border-emerald-400/30 text-emerald-300">
{memory.origin}
</span>
)}
<span className="ml-auto text-xs font-mono text-gray-500">
{formatTimestamp(memory.created_at)}
</span>
</div>
<h1 className="text-4xl font-black text-transparent bg-clip-text bg-gradient-to-r from-blue-300 via-purple-300 to-emerald-300 mb-4 leading-tight">
{memory.title}
</h1>
{memory.subtitle && (
<p className="text-xl text-gray-400 leading-relaxed">
{memory.subtitle}
</p>
)}
</div>
{memory.facts?.length > 0 && (
<div className="mb-8">
<h3 className="text-sm font-bold text-blue-400 mb-4 flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-blue-400" />
FACTS EXTRACTED
</h3>
<div className="space-y-3">
{memory.facts.map((fact, i) => (
<div key={i} className="flex gap-3 text-gray-300 leading-relaxed" style={{
animation: 'fadeInUp 0.5s ease-out',
animationDelay: `${i * 0.1}s`,
animationFillMode: 'both'
}}>
<span className="text-blue-400 font-mono text-xs mt-1"></span>
<span>{fact}</span>
</div>
))}
</div>
</div>
)}
{memory.concepts?.length > 0 && (
<div className="mb-8">
<h3 className="text-sm font-bold text-purple-400 mb-4 flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-purple-400" />
CONCEPTS
</h3>
<div className="flex flex-wrap gap-2">
{memory.concepts.map((concept, i) => (
<span key={i} className="px-3 py-1.5 rounded-lg bg-purple-500/10 border border-purple-400/30 text-purple-300 text-sm font-medium" style={{
animation: 'fadeInUp 0.5s ease-out',
animationDelay: `${i * 0.05}s`,
animationFillMode: 'both'
}}>
{concept}
</span>
))}
</div>
</div>
)}
{memory.files_touched?.length > 0 && (
<div>
<h3 className="text-sm font-bold text-emerald-400 mb-4 flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
FILES TOUCHED
</h3>
<div className="space-y-2">
{memory.files_touched.map((file, i) => (
<div key={i} className="flex items-center gap-2 text-sm font-mono text-emerald-300/80" style={{
animation: 'fadeInUp 0.5s ease-out',
animationDelay: `${i * 0.1}s`,
animationFillMode: 'both'
}}>
<span>📄</span>
<span>{file}</span>
</div>
))}
</div>
</div>
)}
<div className="mt-8 pt-6 border-t border-gray-800 flex items-center justify-between">
<div className="text-xs font-mono text-gray-600">
session: {memory.session_id?.substring(0, 8)}...{memory.session_id?.slice(-4)}
</div>
</div>
</div>
</div>
<div className="mt-6 text-center text-xs text-gray-600">
<p> arrow keys to navigate</p>
</div>
</div>
)}
</div>
)}
</main>
{/* Mobile overviews drawer */}
<Dialog open={overviewsOpen} onClose={setOverviewsOpen} className="relative z-50 xl:hidden">
<DialogBackdrop
transition
className="fixed inset-0 bg-gray-900/80 transition-opacity duration-300 ease-linear data-[closed]:opacity-0"
/>
<div className="fixed inset-0 flex justify-end">
<DialogPanel
transition
className="relative ml-16 flex w-full max-w-xs flex-1 transform transition duration-300 ease-in-out data-[closed]:translate-x-full"
>
<TransitionChild>
<div className="absolute right-full top-0 flex w-16 justify-center pt-5 duration-300 ease-in-out data-[closed]:opacity-0">
<button type="button" onClick={() => setOverviewsOpen(false)} className="-m-2.5 p-2.5">
<span className="sr-only">Close overviews</span>
<XMarkIcon aria-hidden="true" className="size-6 text-white" />
</button>
</div>
</TransitionChild>
<div className="relative flex grow flex-col overflow-y-auto bg-gray-900/90 backdrop-blur-xl border-l border-gray-800">
<header className="flex items-center justify-between border-b border-gray-800 px-4 py-4 sm:px-6">
<h2 className="text-base/7 font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-300 to-purple-300">Session Overviews</h2>
<span className="text-sm font-mono text-gray-500">{filteredOverviews.length}</span>
</header>
<ul role="list" className="divide-y divide-gray-800">
{filteredOverviews.length === 0 && (
<li className="px-4 py-12 text-center">
<div className="relative inline-block">
<div className="absolute inset-0 bg-purple-500/10 blur-2xl" />
<div className="relative text-4xl mb-3 opacity-50">📋</div>
</div>
<p className="text-sm text-gray-500">No overviews yet</p>
</li>
)}
{filteredOverviews.map((overview) => (
<li key={overview.id} className="px-4 py-4 sm:px-6 hover:bg-gray-800/30 transition-colors">
<div className="flex items-start gap-3 mb-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="px-2 py-0.5 rounded text-xs font-bold bg-purple-500/10 border border-purple-400/30 text-purple-300">
#{overview.id}
</span>
{overview.isNew && (
<span className="px-2 py-0.5 rounded text-xs font-bold bg-blue-500/20 border border-blue-400/40 text-blue-300 animate-pulse">
NEW
</span>
)}
</div>
<div className="text-xs font-mono text-gray-500 truncate">
{overview.project}
</div>
</div>
<div className="text-xs text-gray-500">
{formatTimestamp(overview.created_at)}
</div>
</div>
{overview.promptTitle && (
<div className="mb-3">
<h3 className="text-sm font-bold text-blue-300 mb-1 leading-snug">
{overview.promptTitle}
</h3>
{overview.promptSubtitle && (
<p className="text-xs text-gray-400 leading-relaxed">
{overview.promptSubtitle}
</p>
)}
</div>
)}
<p className="text-sm text-gray-300 leading-relaxed line-clamp-6">
{overview.content}
</p>
<div className="mt-2 pt-2 border-t border-gray-800">
<div className="text-xs font-mono text-gray-600 truncate">
session: {overview.session_id?.substring(0, 8)}...{overview.session_id?.slice(-4)}
</div>
</div>
</li>
))}
</ul>
</div>
</DialogPanel>
</div>
</Dialog>
{/* Desktop overviews sidebar */}
<aside className="hidden xl:block bg-gray-900/90 backdrop-blur-xl xl:fixed xl:bottom-0 xl:right-0 xl:top-16 xl:w-96 xl:overflow-y-auto xl:border-l xl:border-gray-800">
<header className="flex items-center justify-between border-b border-gray-800 px-4 py-4 sm:px-6 lg:px-8">
<h2 className="text-base/7 font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-300 to-purple-300">Session Overviews</h2>
<span className="text-sm font-mono text-gray-500">{filteredOverviews.length}</span>
</header>
<ul role="list" className="divide-y divide-gray-800">
{filteredOverviews.length === 0 && (
<li className="px-4 py-12 text-center">
<div className="relative inline-block">
<div className="absolute inset-0 bg-purple-500/10 blur-2xl" />
<div className="relative text-4xl mb-3 opacity-50">📋</div>
</div>
<p className="text-sm text-gray-500">No overviews yet</p>
</li>
)}
{filteredOverviews.map((overview) => (
<li key={overview.id} className="px-4 py-4 sm:px-6 lg:px-8 hover:bg-gray-800/30 transition-colors">
<div className="flex items-start gap-3 mb-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="px-2 py-0.5 rounded text-xs font-bold bg-purple-500/10 border border-purple-400/30 text-purple-300">
#{overview.id}
</span>
{overview.isNew && (
<span className="px-2 py-0.5 rounded text-xs font-bold bg-blue-500/20 border border-blue-400/40 text-blue-300 animate-pulse">
NEW
</span>
)}
</div>
<div className="text-xs font-mono text-gray-500 truncate">
{overview.project}
</div>
</div>
<div className="text-xs text-gray-500">
{formatTimestamp(overview.created_at)}
</div>
</div>
{overview.promptTitle && (
<div className="mb-3">
<h3 className="text-sm font-bold text-blue-300 mb-1 leading-snug">
{overview.promptTitle}
</h3>
{overview.promptSubtitle && (
<p className="text-xs text-gray-400 leading-relaxed">
{overview.promptSubtitle}
</p>
)}
</div>
)}
<p className="text-sm text-gray-300 leading-relaxed line-clamp-6">
{overview.content}
</p>
<div className="mt-2 pt-2 border-t border-gray-800">
<div className="text-xs font-mono text-gray-600 truncate">
session: {overview.session_id?.substring(0, 8)}...{overview.session_id?.slice(-4)}
</div>
</div>
</li>
))}
</ul>
</aside>
</div>
</div>
<style>{`
@keyframes scan {
0%, 100% {
transform: translateX(-100%);
opacity: 0;
}
50% {
transform: translateX(100%);
opacity: 1;
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`}</style>
</>
);
}
+101
View File
@@ -0,0 +1,101 @@
# Memory Stream - Live Memory Viewer
A real-time slideshow viewer for claude-mem memories with SSE (Server-Sent Events) support.
## Features
- 📡 **Live streaming** - Automatically displays new memories as they're created
- 🎬 **Auto-slideshow** - Cycles through memories every 5 seconds
- ⏸️ **Pause/Resume** - Space bar or button controls
- ⌨️ **Keyboard navigation** - Arrow keys to navigate
- 🎨 **Beautiful UI** - Cyberpunk-themed neural network aesthetic
## Setup
### 1. Start the SSE server
```bash
node src/ui/memory-stream/server.js
# or use the package script:
npm run memory-stream:server
```
This will:
- Watch `~/.claude-mem/claude-mem.db-wal` for changes
- Serve SSE events on `http://localhost:3001/stream`
- Automatically detect and broadcast new memories
### 2. Start your React dev server
```bash
# In your React app directory
npm run dev
# or
bun dev
```
### 3. Open the viewer
Navigate to your React app (usually `http://localhost:5173`)
## Usage
### Live Mode (Recommended)
1. Click **"CONNECT LIVE STREAM"**
2. Server must be running (`node memory-stream-server.js`)
3. New memories appear automatically as they're created
4. Perfect for real-time monitoring during Claude Code sessions
### Presentation Mode (Alternative)
1. Click **"START PRESENTATION"**
2. Select your `~/.claude-mem/claude-mem.db` file
3. Static slideshow of existing memories
4. No server required
## Controls
- **Space** - Pause/Resume slideshow
- **←** - Previous memory
- **→** - Next memory
- **Click buttons** - Same as keyboard controls
## How It Works
### SSE Server
- Uses `better-sqlite3` with WAL mode (already enabled in claude-mem)
- Watches the `-wal` file for changes using `fs.watch()`
- Queries for new memories when WAL changes detected
- Broadcasts to all connected clients via Server-Sent Events
### React Client
- Connects to SSE endpoint via `EventSource`
- Auto-reconnects on connection loss
- Appends new memories to the slideshow in real-time
- No polling, pure event-driven updates
## Technical Details
**Database**: SQLite with WAL (Write-Ahead Logging) mode
**Change Detection**: `fs.watch()` on `claude-mem.db-wal`
**Transport**: Server-Sent Events (SSE)
**Auto-reconnect**: 2-second retry on connection loss
**CORS**: Enabled for local development
## Troubleshooting
**"Connection lost"**
- Ensure server is running: `node src/ui/memory-stream/server.js`
- Check port 3001 is available
- Look for server console output
**No memories showing**
- Verify memories exist with `title` field
- Check database path: `~/.claude-mem/claude-mem.db`
- Try "START PRESENTATION" mode to verify database access
**WAL file not found**
- WAL mode auto-enabled by claude-mem
- File created automatically on first write
- Check database exists at expected path
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": false,
"tailwind": {
"config": "",
"css": "index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Memory Stream - Claude Mem</title>
<script type="module" crossorigin src="/assets/index-BjZoir4u.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-5_3SV7cT.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+120
View File
@@ -0,0 +1,120 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Memory Stream - Claude Mem</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.jsx"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
export { default } from './MemoryStream.jsx';
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
+604
View File
@@ -0,0 +1,604 @@
'use client'
import { useState } from 'react'
import {
Dialog,
DialogBackdrop,
DialogPanel,
Menu,
MenuButton,
MenuItem,
MenuItems,
TransitionChild,
} from '@headlessui/react'
import {
ChartBarSquareIcon,
Cog6ToothIcon,
FolderIcon,
GlobeAltIcon,
ServerIcon,
SignalIcon,
XMarkIcon,
} from '@heroicons/react/24/outline'
import { Bars3Icon, ChevronRightIcon, ChevronUpDownIcon, MagnifyingGlassIcon } from '@heroicons/react/20/solid'
const navigation = [
{ name: 'Projects', href: '#', icon: FolderIcon, current: false },
{ name: 'Deployments', href: '#', icon: ServerIcon, current: true },
{ name: 'Activity', href: '#', icon: SignalIcon, current: false },
{ name: 'Domains', href: '#', icon: GlobeAltIcon, current: false },
{ name: 'Usage', href: '#', icon: ChartBarSquareIcon, current: false },
{ name: 'Settings', href: '#', icon: Cog6ToothIcon, current: false },
]
const teams = [
{ id: 1, name: 'Planetaria', href: '#', initial: 'P', current: false },
{ id: 2, name: 'Protocol', href: '#', initial: 'P', current: false },
{ id: 3, name: 'Tailwind Labs', href: '#', initial: 'T', current: false },
]
const statuses = {
offline: 'text-gray-400 bg-gray-100 dark:text-gray-500 dark:bg-gray-100/10',
online: 'text-green-500 bg-green-500/10 dark:text-green-400 dark:bg-green-400/10',
error: 'text-rose-500 bg-rose-500/10 dark:text-rose-400 dark:bg-rose-400/10',
}
const environments = {
Preview: 'text-gray-500 bg-gray-50 ring-gray-200 dark:text-gray-400 dark:bg-gray-400/10 dark:ring-gray-400/20',
Production:
'text-indigo-500 bg-indigo-50 ring-indigo-200 dark:text-indigo-400 dark:bg-indigo-400/10 dark:ring-indigo-400/30',
}
const deployments = [
{
id: 1,
href: '#',
projectName: 'ios-app',
teamName: 'Planetaria',
status: 'offline',
statusText: 'Initiated 1m 32s ago',
description: 'Deploys from GitHub',
environment: 'Preview',
},
{
id: 2,
href: '#',
projectName: 'mobile-api',
teamName: 'Planetaria',
status: 'online',
statusText: 'Deployed 3m ago',
description: 'Deploys from GitHub',
environment: 'Production',
},
{
id: 3,
href: '#',
projectName: 'tailwindcss.com',
teamName: 'Tailwind Labs',
status: 'offline',
statusText: 'Deployed 3h ago',
description: 'Deploys from GitHub',
environment: 'Preview',
},
{
id: 4,
href: '#',
projectName: 'company-website',
teamName: 'Tailwind Labs',
status: 'online',
statusText: 'Deployed 1d ago',
description: 'Deploys from GitHub',
environment: 'Preview',
},
{
id: 5,
href: '#',
projectName: 'relay-service',
teamName: 'Protocol',
status: 'online',
statusText: 'Deployed 1d ago',
description: 'Deploys from GitHub',
environment: 'Production',
},
{
id: 6,
href: '#',
projectName: 'android-app',
teamName: 'Planetaria',
status: 'online',
statusText: 'Deployed 5d ago',
description: 'Deploys from GitHub',
environment: 'Preview',
},
{
id: 7,
href: '#',
projectName: 'api.protocol.chat',
teamName: 'Protocol',
status: 'error',
statusText: 'Failed to deploy 6d ago',
description: 'Deploys from GitHub',
environment: 'Preview',
},
{
id: 8,
href: '#',
projectName: 'planetaria.tech',
teamName: 'Planetaria',
status: 'online',
statusText: 'Deployed 6d ago',
description: 'Deploys from GitHub',
environment: 'Preview',
},
]
const activityItems = [
{
user: {
name: 'Michael Foster',
imageUrl:
'https://images.unsplash.com/photo-1519244703995-f4e0f30006d5?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
},
projectName: 'ios-app',
commit: '2d89f0c8',
branch: 'main',
date: '1h',
dateTime: '2023-01-23T11:00',
},
{
user: {
name: 'Lindsay Walton',
imageUrl:
'https://images.unsplash.com/photo-1517841905240-472988babdf9?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
},
projectName: 'mobile-api',
commit: '249df660',
branch: 'main',
date: '3h',
dateTime: '2023-01-23T09:00',
},
{
user: {
name: 'Courtney Henry',
imageUrl:
'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
},
projectName: 'ios-app',
commit: '11464223',
branch: 'main',
date: '12h',
dateTime: '2023-01-23T00:00',
},
{
user: {
name: 'Courtney Henry',
imageUrl:
'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
},
projectName: 'company-website',
commit: 'dad28e95',
branch: 'main',
date: '2d',
dateTime: '2023-01-21T13:00',
},
{
user: {
name: 'Michael Foster',
imageUrl:
'https://images.unsplash.com/photo-1519244703995-f4e0f30006d5?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
},
projectName: 'relay-service',
commit: '624bc94c',
branch: 'main',
date: '5d',
dateTime: '2023-01-18T12:34',
},
{
user: {
name: 'Courtney Henry',
imageUrl:
'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
},
projectName: 'api.protocol.chat',
commit: 'e111f80e',
branch: 'main',
date: '1w',
dateTime: '2023-01-16T15:54',
},
{
user: {
name: 'Michael Foster',
imageUrl:
'https://images.unsplash.com/photo-1519244703995-f4e0f30006d5?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
},
projectName: 'api.protocol.chat',
commit: '5e136005',
branch: 'main',
date: '1w',
dateTime: '2023-01-16T11:31',
},
{
user: {
name: 'Whitney Francis',
imageUrl:
'https://images.unsplash.com/photo-1517365830460-955ce3ccd263?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
},
projectName: 'ios-app',
commit: '5c1fd07f',
branch: 'main',
date: '2w',
dateTime: '2023-01-09T08:45',
},
]
function classNames(...classes) {
return classes.filter(Boolean).join(' ')
}
export default function Example() {
const [sidebarOpen, setSidebarOpen] = useState(false)
return (
<>
{/*
This example requires updating your template:
```
<html class="h-full bg-white dark:bg-gray-900">
<body class="h-full">
```
*/}
<div>
<Dialog open={sidebarOpen} onClose={setSidebarOpen} className="relative z-50 xl:hidden">
<DialogBackdrop
transition
className="fixed inset-0 bg-gray-900/80 transition-opacity duration-300 ease-linear data-[closed]:opacity-0"
/>
<div className="fixed inset-0 flex">
<DialogPanel
transition
className="relative mr-16 flex w-full max-w-xs flex-1 transform transition duration-300 ease-in-out data-[closed]:-translate-x-full"
>
<TransitionChild>
<div className="absolute left-full top-0 flex w-16 justify-center pt-5 duration-300 ease-in-out data-[closed]:opacity-0">
<button type="button" onClick={() => setSidebarOpen(false)} className="-m-2.5 p-2.5">
<span className="sr-only">Close sidebar</span>
<XMarkIcon aria-hidden="true" className="size-6 text-white" />
</button>
</div>
</TransitionChild>
{/* Sidebar component, swap this element with another sidebar if you like */}
<div className="relative flex grow flex-col gap-y-5 overflow-y-auto bg-gray-50 px-6 dark:bg-gray-900 dark:ring dark:ring-white/10 dark:before:pointer-events-none dark:before:absolute dark:before:inset-0 dark:before:bg-black/10">
<div className="relative flex h-16 shrink-0 items-center">
<img
alt="Your Company"
src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=600"
className="h-8 w-auto dark:hidden"
/>
<img
alt="Your Company"
src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=500"
className="hidden h-8 w-auto dark:block"
/>
</div>
<nav className="relative flex flex-1 flex-col">
<ul role="list" className="flex flex-1 flex-col gap-y-7">
<li>
<ul role="list" className="-mx-2 space-y-1">
{navigation.map((item) => (
<li key={item.name}>
<a
href={item.href}
className={classNames(
item.current
? 'bg-gray-100 text-indigo-600 dark:bg-white/5 dark:text-white'
: 'text-gray-700 hover:bg-gray-100 hover:text-indigo-600 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white',
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
)}
>
<item.icon
aria-hidden="true"
className={classNames(
item.current
? 'text-indigo-600 dark:text-white'
: 'text-gray-400 group-hover:text-indigo-600 dark:group-hover:text-white',
'size-6 shrink-0',
)}
/>
{item.name}
</a>
</li>
))}
</ul>
</li>
<li>
<div className="text-xs/6 font-semibold text-gray-400">Your teams</div>
<ul role="list" className="-mx-2 mt-2 space-y-1">
{teams.map((team) => (
<li key={team.name}>
<a
href={team.href}
className={classNames(
team.current
? 'bg-gray-100 text-indigo-600 dark:bg-white/5 dark:text-white'
: 'text-gray-700 hover:bg-gray-100 hover:text-indigo-600 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white',
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
)}
>
<span
className={classNames(
team.current
? 'border-indigo-600 text-indigo-600 dark:border-white/20 dark:text-white'
: 'border-gray-200 text-gray-400 group-hover:border-indigo-600 group-hover:text-indigo-600 dark:border-white/10 dark:group-hover:border-white/20 dark:group-hover:text-white',
'flex size-6 shrink-0 items-center justify-center rounded-lg border bg-white text-[0.625rem] font-medium dark:bg-white/5',
)}
>
{team.initial}
</span>
<span className="truncate">{team.name}</span>
</a>
</li>
))}
</ul>
</li>
<li className="-mx-6 mt-auto">
<a
href="#"
className="flex items-center gap-x-4 px-6 py-3 text-sm/6 font-semibold text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-white/5"
>
<img
alt=""
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
className="size-8 rounded-full bg-gray-100 outline outline-1 -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
/>
<span className="sr-only">Your profile</span>
<span aria-hidden="true">Tom Cook</span>
</a>
</li>
</ul>
</nav>
</div>
</DialogPanel>
</div>
</Dialog>
{/* Static sidebar for desktop */}
<div className="hidden xl:fixed xl:inset-y-0 xl:z-50 xl:flex xl:w-72 xl:flex-col dark:bg-gray-900">
{/* Sidebar component, swap this element with another sidebar if you like */}
<div className="flex grow flex-col gap-y-5 overflow-y-auto bg-gray-50 px-6 ring-1 ring-gray-200 dark:bg-black/10 dark:ring-white/5">
<div className="flex h-16 shrink-0 items-center">
<img
alt="Your Company"
src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=600"
className="h-8 w-auto dark:hidden"
/>
<img
alt="Your Company"
src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=500"
className="hidden h-8 w-auto dark:block"
/>
</div>
<nav className="flex flex-1 flex-col">
<ul role="list" className="flex flex-1 flex-col gap-y-7">
<li>
<ul role="list" className="-mx-2 space-y-1">
{navigation.map((item) => (
<li key={item.name}>
<a
href={item.href}
className={classNames(
item.current
? 'bg-gray-100 text-indigo-600 dark:bg-white/5 dark:text-white'
: 'text-gray-700 hover:bg-gray-100 hover:text-indigo-600 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white',
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
)}
>
<item.icon
aria-hidden="true"
className={classNames(
item.current
? 'text-indigo-600 dark:text-white'
: 'text-gray-400 group-hover:text-indigo-600 dark:group-hover:text-white',
'size-6 shrink-0',
)}
/>
{item.name}
</a>
</li>
))}
</ul>
</li>
<li>
<div className="text-xs/6 font-semibold text-gray-500 dark:text-gray-400">Your teams</div>
<ul role="list" className="-mx-2 mt-2 space-y-1">
{teams.map((team) => (
<li key={team.name}>
<a
href={team.href}
className={classNames(
team.current
? 'bg-gray-100 text-indigo-600 dark:bg-white/5 dark:text-white'
: 'text-gray-700 hover:bg-gray-100 hover:text-indigo-600 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white',
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
)}
>
<span
className={classNames(
team.current
? 'border-indigo-600 text-indigo-600 dark:border-white/20 dark:text-white'
: 'border-gray-200 text-gray-400 group-hover:border-indigo-600 group-hover:text-indigo-600 dark:border-white/10 dark:group-hover:border-white/20 dark:group-hover:text-white',
'flex size-6 shrink-0 items-center justify-center rounded-lg border bg-white text-[0.625rem] font-medium dark:bg-white/5',
)}
>
{team.initial}
</span>
<span className="truncate">{team.name}</span>
</a>
</li>
))}
</ul>
</li>
<li className="-mx-6 mt-auto">
<a
href="#"
className="flex items-center gap-x-4 px-6 py-3 text-sm/6 font-semibold text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-white/5"
>
<img
alt=""
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
className="size-8 rounded-full bg-gray-100 outline outline-1 -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
/>
<span className="sr-only">Your profile</span>
<span aria-hidden="true">Tom Cook</span>
</a>
</li>
</ul>
</nav>
</div>
</div>
<div className="xl:pl-72">
{/* Sticky search header */}
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-6 border-b border-gray-200 bg-white px-4 shadow-sm sm:px-6 lg:px-8 dark:border-white/5 dark:bg-gray-900 dark:shadow-none">
<button
type="button"
onClick={() => setSidebarOpen(true)}
className="-m-2.5 p-2.5 text-gray-900 xl:hidden dark:text-white"
>
<span className="sr-only">Open sidebar</span>
<Bars3Icon aria-hidden="true" className="size-5" />
</button>
<div className="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
<form action="#" method="GET" className="grid flex-1 grid-cols-1">
<input
name="search"
placeholder="Search"
aria-label="Search"
className="col-start-1 row-start-1 block size-full bg-transparent pl-8 text-base text-gray-900 outline-none placeholder:text-gray-400 sm:text-sm/6 dark:text-white dark:placeholder:text-gray-500"
/>
<MagnifyingGlassIcon
aria-hidden="true"
className="pointer-events-none col-start-1 row-start-1 size-5 self-center text-gray-400 dark:text-gray-500"
/>
</form>
</div>
</div>
<main className="lg:pr-96">
<header className="flex items-center justify-between border-b border-gray-200 px-4 py-4 sm:px-6 sm:py-6 lg:px-8 dark:border-white/5">
<h1 className="text-base/7 font-semibold text-gray-900 dark:text-white">Deployments</h1>
{/* Sort dropdown */}
<Menu as="div" className="relative">
<MenuButton className="flex items-center gap-x-1 text-sm/6 font-medium text-gray-900 dark:text-white">
Sort by
<ChevronUpDownIcon aria-hidden="true" className="size-5 text-gray-500" />
</MenuButton>
<MenuItems
transition
className="absolute right-0 z-10 mt-2.5 w-40 origin-top-right rounded-md bg-white py-2 shadow-lg outline outline-1 outline-gray-900/5 transition data-[closed]:scale-95 data-[closed]:transform data-[closed]:opacity-0 data-[enter]:duration-100 data-[leave]:duration-75 data-[enter]:ease-out data-[leave]:ease-in dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
>
<MenuItem>
<a
href="#"
className="block px-3 py-1 text-sm/6 text-gray-900 data-[focus]:bg-gray-50 data-[focus]:outline-none dark:text-white dark:data-[focus]:bg-white/5"
>
Name
</a>
</MenuItem>
<MenuItem>
<a
href="#"
className="block px-3 py-1 text-sm/6 text-gray-900 data-[focus]:bg-gray-50 data-[focus]:outline-none dark:text-white dark:data-[focus]:bg-white/5"
>
Date updated
</a>
</MenuItem>
<MenuItem>
<a
href="#"
className="block px-3 py-1 text-sm/6 text-gray-900 data-[focus]:bg-gray-50 data-[focus]:outline-none dark:text-white dark:data-[focus]:bg-white/5"
>
Environment
</a>
</MenuItem>
</MenuItems>
</Menu>
</header>
{/* Deployment list */}
<ul role="list" className="divide-y divide-gray-100 dark:divide-white/5">
{deployments.map((deployment) => (
<li key={deployment.id} className="relative flex items-center space-x-4 px-4 py-4 sm:px-6 lg:px-8">
<div className="min-w-0 flex-auto">
<div className="flex items-center gap-x-3">
<div className={classNames(statuses[deployment.status], 'flex-none rounded-full p-1')}>
<div className="size-2 rounded-full bg-current" />
</div>
<h2 className="min-w-0 text-sm/6 font-semibold text-gray-900 dark:text-white">
<a href={deployment.href} className="flex gap-x-2">
<span className="truncate">{deployment.teamName}</span>
<span className="text-gray-400">/</span>
<span className="whitespace-nowrap">{deployment.projectName}</span>
<span className="absolute inset-0" />
</a>
</h2>
</div>
<div className="mt-3 flex items-center gap-x-2.5 text-xs/5 text-gray-500 dark:text-gray-400">
<p className="truncate">{deployment.description}</p>
<svg viewBox="0 0 2 2" className="size-0.5 flex-none fill-gray-300 dark:fill-gray-500">
<circle r={1} cx={1} cy={1} />
</svg>
<p className="whitespace-nowrap">{deployment.statusText}</p>
</div>
</div>
<div
className={classNames(
environments[deployment.environment],
'flex-none rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset',
)}
>
{deployment.environment}
</div>
<ChevronRightIcon aria-hidden="true" className="size-5 flex-none text-gray-400" />
</li>
))}
</ul>
</main>
{/* Activity feed */}
<aside className="bg-gray-50 lg:fixed lg:bottom-0 lg:right-0 lg:top-16 lg:w-96 lg:overflow-y-auto lg:border-l lg:border-gray-200 dark:bg-black/10 dark:lg:border-white/5">
<header className="flex items-center justify-between border-b border-gray-200 px-4 py-4 sm:px-6 sm:py-6 lg:px-8 dark:border-white/5">
<h2 className="text-base/7 font-semibold text-gray-900 dark:text-white">Activity feed</h2>
<a href="#" className="text-sm/6 font-semibold text-indigo-600 dark:text-indigo-400">
View all
</a>
</header>
<ul role="list" className="divide-y divide-gray-100 dark:divide-white/5">
{activityItems.map((item) => (
<li key={item.commit} className="px-4 py-4 sm:px-6 lg:px-8">
<div className="flex items-center gap-x-3">
<img
alt=""
src={item.user.imageUrl}
className="size-6 flex-none rounded-full bg-gray-100 outline outline-1 -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
/>
<h3 className="flex-auto truncate text-sm/6 font-semibold text-gray-900 dark:text-white">
{item.user.name}
</h3>
<time dateTime={item.dateTime} className="flex-none text-xs text-gray-500 dark:text-gray-600">
{item.date}
</time>
</div>
<p className="mt-3 truncate text-sm text-gray-500">
Pushed to <span className="text-gray-700 dark:text-gray-400">{item.projectName}</span> (
<span className="font-mono text-gray-700 dark:text-gray-400">{item.commit}</span> on{' '}
<span className="text-gray-700 dark:text-gray-400">{item.branch}</span>)
</p>
</li>
))}
</ul>
</aside>
</div>
</div>
</>
)
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import MemoryStream from './MemoryStream.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<MemoryStream />
</React.StrictMode>,
)
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "memory-stream-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@headlessui/react": "^2.2.9",
"@heroicons/react": "^2.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.544.0",
"ogl": "^1.0.11",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwind-merge": "^3.3.1",
"three": "^0.180.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.14",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.14",
"tw-animate-css": "^1.4.0",
"vite": "^5.0.8"
}
}
+232
View File
@@ -0,0 +1,232 @@
#!/usr/bin/env node
import { watch, existsSync, readFileSync } from 'fs';
import { createServer } from 'http';
import { homedir } from 'os';
import { join } from 'path';
import Database from 'better-sqlite3';
const DB_PATH = join(homedir(), '.claude-mem/claude-mem.db');
const SESSIONS_DIR = join(homedir(), '.claude-mem/sessions');
const PORT = 3001;
let clients = [];
let lastMaxId = 0;
let lastOverviewId = 0;
function safeJsonParse(jsonString) {
if (!jsonString) return [];
try {
return JSON.parse(jsonString);
} catch {
return [];
}
}
function getMemories(minId = 0) {
const db = new Database(DB_PATH, { readonly: true });
const memories = db.prepare(`
SELECT id, session_id, created_at, project, origin, title, subtitle, facts, concepts, files_touched
FROM memories
WHERE id > ? AND title IS NOT NULL
ORDER BY id DESC
`).all(minId);
db.close();
return memories.map(m => ({
...m,
facts: safeJsonParse(m.facts),
concepts: safeJsonParse(m.concepts),
files_touched: safeJsonParse(m.files_touched)
}));
}
function getOverviews(minId = 0) {
const db = new Database(DB_PATH, { readonly: true });
const overviews = db.prepare(`
SELECT id, session_id, content, created_at, project, origin
FROM overviews
WHERE id > ?
ORDER BY id DESC
`).all(minId);
db.close();
// Enrich overviews with session titles/subtitles from session JSON files
return overviews.map(overview => {
const sessionFile = join(SESSIONS_DIR, `${overview.project}_streaming.json`);
let promptTitle = null;
let promptSubtitle = null;
try {
if (existsSync(sessionFile)) {
const sessionData = JSON.parse(readFileSync(sessionFile, 'utf8'));
// Only attach title/subtitle if it's from the same Claude session
if (sessionData.claudeSessionId === overview.session_id) {
promptTitle = sessionData.promptTitle || null;
promptSubtitle = sessionData.promptSubtitle || null;
}
}
} catch (e) {
// Ignore errors reading session file
}
return {
...overview,
promptTitle,
promptSubtitle
};
});
}
function getSessions() {
const db = new Database(DB_PATH, { readonly: true });
// Get unique sessions from overviews
const sessions = db.prepare(`
SELECT DISTINCT
o.session_id,
o.project,
o.created_at,
o.content as overview_content
FROM overviews o
ORDER BY o.created_at DESC
LIMIT 50
`).all();
db.close();
return sessions;
}
function getSessionData(sessionId) {
const db = new Database(DB_PATH, { readonly: true });
const overview = db.prepare(`
SELECT id, session_id, content, created_at, project, origin
FROM overviews
WHERE session_id = ?
LIMIT 1
`).get(sessionId);
const memories = db.prepare(`
SELECT id, session_id, created_at, project, origin, title, subtitle, facts, concepts, files_touched
FROM memories
WHERE session_id = ? AND title IS NOT NULL
ORDER BY id ASC
`).all(sessionId);
db.close();
return {
overview,
memories: memories.map(m => ({
...m,
facts: safeJsonParse(m.facts),
concepts: safeJsonParse(m.concepts),
files_touched: safeJsonParse(m.files_touched)
}))
};
}
function broadcast(type, data) {
const message = `data: ${JSON.stringify({ type, ...data })}\n\n`;
clients.forEach(client => client.write(message));
}
function broadcastSessionState(eventType, project) {
const message = `data: ${JSON.stringify({ type: eventType, project })}\n\n`;
clients.forEach(client => client.write(message));
console.log(`📡 Broadcasting ${eventType} for project: ${project}`);
}
const server = createServer((req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
if (req.url === '/stream') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
clients.push(res);
console.log(`🔌 Client connected (${clients.length} total)`);
const allMemories = getMemories(-1);
lastMaxId = allMemories.length > 0 ? Math.max(...allMemories.map(m => m.id)) : 0;
const allOverviews = getOverviews(-1);
lastOverviewId = allOverviews.length > 0 ? Math.max(...allOverviews.map(o => o.id)) : 0;
console.log(`📦 Sending ${allMemories.length} memories and ${allOverviews.length} overviews to new client`);
broadcast('initial_load', { memories: allMemories, overviews: allOverviews });
req.on('close', () => {
clients = clients.filter(client => client !== res);
console.log(`🔌 Client disconnected (${clients.length} remaining)`);
});
} else if (req.url === '/api/sessions') {
res.writeHead(200, { 'Content-Type': 'application/json' });
const sessions = getSessions();
res.end(JSON.stringify(sessions));
} else if (req.url.startsWith('/api/session/')) {
const sessionId = req.url.replace('/api/session/', '');
res.writeHead(200, { 'Content-Type': 'application/json' });
const sessionData = getSessionData(sessionId);
res.end(JSON.stringify(sessionData));
} else {
res.writeHead(404);
res.end();
}
});
watch(DB_PATH, (eventType) => {
const newMemories = getMemories(lastMaxId);
if (newMemories.length > 0) {
lastMaxId = Math.max(...newMemories.map(m => m.id));
console.log(`✨ Broadcasting ${newMemories.length} new memories`);
broadcast('new_memories', { memories: newMemories });
}
const newOverviews = getOverviews(lastOverviewId);
if (newOverviews.length > 0) {
lastOverviewId = Math.max(...newOverviews.map(o => o.id));
console.log(`✨ Broadcasting ${newOverviews.length} new overviews`);
broadcast('new_overviews', { overviews: newOverviews });
}
});
watch(SESSIONS_DIR, (eventType, filename) => {
if (!filename || !filename.endsWith('_streaming.json')) return;
const project = filename.replace('_streaming.json', '');
const sessionPath = join(SESSIONS_DIR, filename);
if (eventType === 'rename') {
// Check if file exists to determine if it was created or deleted
if (existsSync(sessionPath)) {
broadcastSessionState('session_start', project);
} else {
broadcastSessionState('session_end', project);
}
}
});
server.listen(PORT, () => {
console.log(`🚀 Memory Stream Server running on http://localhost:${PORT}`);
console.log(`📡 SSE endpoint: http://localhost:${PORT}/stream`);
});
process.on('SIGINT', () => {
clients.forEach(client => client.end());
server.close();
process.exit(0);
});
@@ -0,0 +1,570 @@
// Component ported and enhanced from https://codepen.io/JuanFuentes/pen/eYEeoyE
import { useRef, useEffect } from 'react';
import * as THREE from 'three';
const vertexShader = `
varying vec2 vUv;
uniform float uTime;
uniform float mouse;
uniform float uEnableWaves;
void main() {
vUv = uv;
float time = uTime * 5.;
float waveFactor = uEnableWaves;
vec3 transformed = position;
transformed.x += sin(time + position.y) * 0.5 * waveFactor;
transformed.y += cos(time + position.z) * 0.15 * waveFactor;
transformed.z += sin(time + position.x) * waveFactor;
gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0);
}
`;
const fragmentShader = `
varying vec2 vUv;
uniform float mouse;
uniform float uTime;
uniform sampler2D uTexture;
void main() {
float time = uTime;
vec2 pos = vUv;
float move = sin(time + mouse) * 0.01;
float r = texture2D(uTexture, pos + cos(time * 2. - time + pos.x) * .01).r;
float g = texture2D(uTexture, pos + tan(time * .5 + pos.x - time) * .01).g;
float b = texture2D(uTexture, pos - cos(time * 2. + time + pos.y) * .01).b;
float a = texture2D(uTexture, pos).a;
gl_FragColor = vec4(r, g, b, a);
}
`;
function map(n, start, stop, start2, stop2) {
return ((n - start) / (stop - start)) * (stop2 - start2) + start2;
}
const PX_RATIO = typeof window !== 'undefined' ? window.devicePixelRatio : 1;
class AsciiFilter {
width = 0;
height = 0;
center = { x: 0, y: 0 };
mouse = { x: 0, y: 0 };
cols = 0;
rows = 0;
constructor(renderer, {
fontSize,
fontFamily,
charset,
invert
} = {}) {
this.renderer = renderer;
this.domElement = document.createElement('div');
this.domElement.style.position = 'absolute';
this.domElement.style.top = '0';
this.domElement.style.left = '0';
this.domElement.style.width = '100%';
this.domElement.style.height = '100%';
this.pre = document.createElement('pre');
this.domElement.appendChild(this.pre);
this.canvas = document.createElement('canvas');
this.context = this.canvas.getContext('2d');
this.domElement.appendChild(this.canvas);
this.deg = 0;
this.invert = invert ?? true;
this.fontSize = fontSize ?? 12;
this.fontFamily = fontFamily ?? "'Courier New', monospace";
this.charset = charset ?? ' .\'`^",:;Il!i~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$';
if (this.context) {
this.context.imageSmoothingEnabled = false;
this.context.imageSmoothingEnabled = false;
}
this.onMouseMove = this.onMouseMove.bind(this);
document.addEventListener('mousemove', this.onMouseMove);
}
setSize(width, height) {
this.width = width;
this.height = height;
this.renderer.setSize(width, height);
this.reset();
this.center = { x: width / 2, y: height / 2 };
this.mouse = { x: this.center.x, y: this.center.y };
}
reset() {
if (this.context) {
this.context.font = `${this.fontSize}px ${this.fontFamily}`;
const charWidth = this.context.measureText('A').width;
this.cols = Math.floor(this.width / (this.fontSize * (charWidth / this.fontSize)));
this.rows = Math.floor(this.height / this.fontSize);
this.canvas.width = this.cols;
this.canvas.height = this.rows;
this.pre.style.fontFamily = this.fontFamily;
this.pre.style.fontSize = `${this.fontSize}px`;
this.pre.style.margin = '0';
this.pre.style.padding = '0';
this.pre.style.lineHeight = '1em';
this.pre.style.position = 'absolute';
this.pre.style.left = '50%';
this.pre.style.top = '50%';
this.pre.style.transform = 'translate(-50%, -50%)';
this.pre.style.zIndex = '9';
this.pre.style.backgroundAttachment = 'fixed';
this.pre.style.mixBlendMode = 'difference';
}
}
render(scene, camera) {
this.renderer.render(scene, camera);
const w = this.canvas.width;
const h = this.canvas.height;
if (this.context) {
this.context.clearRect(0, 0, w, h);
if (this.context && w && h) {
this.context.drawImage(this.renderer.domElement, 0, 0, w, h);
}
this.asciify(this.context, w, h);
this.hue();
}
}
onMouseMove(e) {
this.mouse = { x: e.clientX * PX_RATIO, y: e.clientY * PX_RATIO };
}
get dx() {
return this.mouse.x - this.center.x;
}
get dy() {
return this.mouse.y - this.center.y;
}
hue() {
const deg = (Math.atan2(this.dy, this.dx) * 180) / Math.PI;
this.deg += (deg - this.deg) * 0.075;
this.domElement.style.filter = `hue-rotate(${this.deg.toFixed(1)}deg)`;
}
asciify(ctx, w, h) {
if (w && h) {
const imgData = ctx.getImageData(0, 0, w, h).data;
let str = '';
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = x * 4 + y * 4 * w;
const [r, g, b, a] = [imgData[i], imgData[i + 1], imgData[i + 2], imgData[i + 3]];
if (a === 0) {
str += ' ';
continue;
}
let gray = (0.3 * r + 0.6 * g + 0.1 * b) / 255;
let idx = Math.floor((1 - gray) * (this.charset.length - 1));
if (this.invert) idx = this.charset.length - idx - 1;
str += this.charset[idx];
}
str += '\n';
}
this.pre.innerHTML = str;
}
}
dispose() {
document.removeEventListener('mousemove', this.onMouseMove);
}
}
class CanvasTxt {
constructor(txt, {
fontSize = 200,
fontFamily = 'Arial',
color = '#fdf9f3'
} = {}) {
this.canvas = document.createElement('canvas');
this.context = this.canvas.getContext('2d');
this.txt = txt;
this.fontSize = fontSize;
this.fontFamily = fontFamily;
this.color = color;
this.font = `600 ${this.fontSize}px ${this.fontFamily}`;
}
resize() {
if (this.context) {
this.context.font = this.font;
// Split text into lines
const lines = this.txt.split('\n');
// Measure all lines to find max width
let maxWidth = 0;
for (const line of lines) {
const metrics = this.context.measureText(line);
maxWidth = Math.max(maxWidth, metrics.width);
}
// Calculate total height (first line metrics for line height)
const firstMetrics = this.context.measureText(lines[0] || 'A');
const lineHeight = Math.ceil(firstMetrics.actualBoundingBoxAscent + firstMetrics.actualBoundingBoxDescent);
const textWidth = Math.ceil(maxWidth) + 20;
const textHeight = lineHeight * lines.length + 20;
this.canvas.width = textWidth;
this.canvas.height = textHeight;
}
}
render() {
if (this.context) {
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.context.fillStyle = this.color;
this.context.font = this.font;
// Split text into lines and render each
const lines = this.txt.split('\n');
const metrics = this.context.measureText(lines[0] || 'A');
const lineHeight = Math.ceil(metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent);
lines.forEach((line, index) => {
const yPos = 10 + metrics.actualBoundingBoxAscent + (index * lineHeight);
this.context.fillText(line, 10, yPos);
});
}
}
get width() {
return this.canvas.width;
}
get height() {
return this.canvas.height;
}
get texture() {
return this.canvas;
}
}
class CanvAscii {
animationFrameId = 0;
constructor(
{
text,
asciiFontSize,
textFontSize,
textColor,
planeBaseHeight,
enableWaves,
enableMouseRotation
},
containerElem,
width,
height
) {
this.textString = text;
this.asciiFontSize = asciiFontSize;
this.textFontSize = textFontSize;
this.textColor = textColor;
this.planeBaseHeight = planeBaseHeight;
this.container = containerElem;
this.width = width;
this.height = height;
this.enableWaves = enableWaves;
this.enableMouseRotation = enableMouseRotation;
this.camera = new THREE.PerspectiveCamera(45, this.width / this.height, 1, 1000);
this.camera.position.z = 30;
this.scene = new THREE.Scene();
this.mouse = { x: 0, y: 0 };
this.onMouseMove = this.onMouseMove.bind(this);
this.setMesh();
this.setRenderer();
}
setMesh() {
this.textCanvas = new CanvasTxt(this.textString, {
fontSize: this.textFontSize,
fontFamily: 'IBM Plex Mono',
color: this.textColor
});
this.textCanvas.resize();
this.textCanvas.render();
this.texture = new THREE.CanvasTexture(this.textCanvas.texture);
this.texture.minFilter = THREE.NearestFilter;
const textAspect = this.textCanvas.width / this.textCanvas.height;
const baseH = this.planeBaseHeight;
const planeW = baseH * textAspect;
const planeH = baseH;
this.geometry = new THREE.PlaneGeometry(planeW, planeH, 36, 36);
this.material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
transparent: true,
uniforms: {
uTime: { value: 0 },
mouse: { value: 1.0 },
uTexture: { value: this.texture },
uEnableWaves: { value: this.enableWaves ? 1.0 : 0.0 }
}
});
this.mesh = new THREE.Mesh(this.geometry, this.material);
this.scene.add(this.mesh);
}
setRenderer() {
this.renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
this.renderer.setPixelRatio(1);
this.renderer.setClearColor(0x000000, 0);
this.filter = new AsciiFilter(this.renderer, {
fontFamily: 'IBM Plex Mono',
fontSize: this.asciiFontSize,
invert: true
});
this.container.appendChild(this.filter.domElement);
this.setSize(this.width, this.height);
this.container.addEventListener('mousemove', this.onMouseMove);
this.container.addEventListener('touchmove', this.onMouseMove);
}
setSize(w, h) {
this.width = w;
this.height = h;
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
this.filter.setSize(w, h);
this.center = { x: w / 2, y: h / 2 };
}
load() {
this.animate();
}
onMouseMove(evt) {
const e = (evt).touches ? (evt).touches[0] : (evt);
const bounds = this.container.getBoundingClientRect();
const x = e.clientX - bounds.left;
const y = e.clientY - bounds.top;
this.mouse = { x, y };
}
animate() {
const animateFrame = () => {
this.animationFrameId = requestAnimationFrame(animateFrame);
this.render();
};
animateFrame();
}
render() {
const time = new Date().getTime() * 0.001;
this.textCanvas.render();
this.texture.needsUpdate = true;
(this.mesh.material).uniforms.uTime.value = Math.sin(time);
this.updateRotation();
this.filter.render(this.scene, this.camera);
}
updateRotation() {
if (!this.enableMouseRotation) return;
const x = map(this.mouse.y, 0, this.height, 0.5, -0.5);
const y = map(this.mouse.x, 0, this.width, -0.5, 0.5);
this.mesh.rotation.x += (x - this.mesh.rotation.x) * 0.05;
this.mesh.rotation.y += (y - this.mesh.rotation.y) * 0.05;
}
clear() {
this.scene.traverse(object => {
const obj = object;
if (!obj.isMesh) return;
[obj.material].flat().forEach(material => {
material.dispose();
Object.keys(material).forEach(key => {
const matProp = material[key];
if (matProp && typeof matProp === 'object' && 'dispose' in matProp && typeof matProp.dispose === 'function') {
matProp.dispose();
}
});
});
obj.geometry.dispose();
});
this.scene.clear();
}
dispose() {
cancelAnimationFrame(this.animationFrameId);
this.filter.dispose();
this.container.removeChild(this.filter.domElement);
this.container.removeEventListener('mousemove', this.onMouseMove);
this.container.removeEventListener('touchmove', this.onMouseMove);
this.clear();
this.renderer.dispose();
}
}
export default function ASCIIText({
text = 'David!',
asciiFontSize = 8,
textFontSize = 200,
textColor = '#fdf9f3',
planeBaseHeight = 8,
enableWaves = true,
enableMouseRotation = true
}) {
const containerRef = useRef(null);
const asciiRef = useRef(null);
useEffect(() => {
if (!containerRef.current) return;
const { width, height } = containerRef.current.getBoundingClientRect();
if (width === 0 || height === 0) {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && entry.boundingClientRect.width > 0 && entry.boundingClientRect.height > 0) {
const { width: w, height: h } = entry.boundingClientRect;
asciiRef.current = new CanvAscii({
text,
asciiFontSize,
textFontSize,
textColor,
planeBaseHeight,
enableWaves,
enableMouseRotation
}, containerRef.current, w, h);
asciiRef.current.load();
observer.disconnect();
}
}, { threshold: 0.1 });
observer.observe(containerRef.current);
return () => {
observer.disconnect();
if (asciiRef.current) {
asciiRef.current.dispose();
}
};
}
asciiRef.current = new CanvAscii({
text,
asciiFontSize,
textFontSize,
textColor,
planeBaseHeight,
enableWaves,
enableMouseRotation
}, containerRef.current, width, height);
asciiRef.current.load();
const ro = new ResizeObserver(entries => {
if (!entries[0] || !asciiRef.current) return;
const { width: w, height: h } = entries[0].contentRect;
if (w > 0 && h > 0) {
asciiRef.current.setSize(w, h);
}
});
ro.observe(containerRef.current);
return () => {
ro.disconnect();
if (asciiRef.current) {
asciiRef.current.dispose();
}
};
}, [text, asciiFontSize, textFontSize, textColor, planeBaseHeight, enableWaves, enableMouseRotation]);
return (
<div
ref={containerRef}
className="ascii-text-container"
style={{
position: 'absolute',
width: '100%',
height: '100%'
}}>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500&display=swap');
body {
margin: 0;
padding: 0;
}
.ascii-text-container canvas {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
image-rendering: optimizeSpeed;
image-rendering: -moz-crisp-edges;
image-rendering: -o-crisp-edges;
image-rendering: -webkit-optimize-contrast;
image-rendering: optimize-contrast;
image-rendering: crisp-edges;
image-rendering: pixelated;
}
.ascii-text-container pre {
margin: 0;
user-select: none;
padding: 0;
line-height: 1em;
text-align: left;
position: absolute;
left: 0;
top: 0;
background-image: radial-gradient(circle, #ff6188 0%, #fc9867 50%, #ffd866 100%);
background-attachment: fixed;
-webkit-text-fill-color: transparent;
-webkit-background-clip: text;
z-index: 9;
mix-blend-mode: difference;
}
`}</style>
</div>
);
}
+274
View File
@@ -0,0 +1,274 @@
import { useEffect, useRef } from 'react';
import { Renderer, Program, Mesh, Triangle, Vec3 } from 'ogl';
export default function Orb({
hue = 0,
hoverIntensity = 0.2,
rotateOnHover = true,
forceHoverState = false
}) {
const ctnDom = useRef(null);
const vert = /* glsl */ `
precision highp float;
attribute vec2 position;
attribute vec2 uv;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 0.0, 1.0);
}
`;
const frag = /* glsl */ `
precision highp float;
uniform float iTime;
uniform vec3 iResolution;
uniform float hue;
uniform float hover;
uniform float rot;
uniform float hoverIntensity;
varying vec2 vUv;
vec3 rgb2yiq(vec3 c) {
float y = dot(c, vec3(0.299, 0.587, 0.114));
float i = dot(c, vec3(0.596, -0.274, -0.322));
float q = dot(c, vec3(0.211, -0.523, 0.312));
return vec3(y, i, q);
}
vec3 yiq2rgb(vec3 c) {
float r = c.x + 0.956 * c.y + 0.621 * c.z;
float g = c.x - 0.272 * c.y - 0.647 * c.z;
float b = c.x - 1.106 * c.y + 1.703 * c.z;
return vec3(r, g, b);
}
vec3 adjustHue(vec3 color, float hueDeg) {
float hueRad = hueDeg * 3.14159265 / 180.0;
vec3 yiq = rgb2yiq(color);
float cosA = cos(hueRad);
float sinA = sin(hueRad);
float i = yiq.y * cosA - yiq.z * sinA;
float q = yiq.y * sinA + yiq.z * cosA;
yiq.y = i;
yiq.z = q;
return yiq2rgb(yiq);
}
vec3 hash33(vec3 p3) {
p3 = fract(p3 * vec3(0.1031, 0.11369, 0.13787));
p3 += dot(p3, p3.yxz + 19.19);
return -1.0 + 2.0 * fract(vec3(
p3.x + p3.y,
p3.x + p3.z,
p3.y + p3.z
) * p3.zyx);
}
float snoise3(vec3 p) {
const float K1 = 0.333333333;
const float K2 = 0.166666667;
vec3 i = floor(p + (p.x + p.y + p.z) * K1);
vec3 d0 = p - (i - (i.x + i.y + i.z) * K2);
vec3 e = step(vec3(0.0), d0 - d0.yzx);
vec3 i1 = e * (1.0 - e.zxy);
vec3 i2 = 1.0 - e.zxy * (1.0 - e);
vec3 d1 = d0 - (i1 - K2);
vec3 d2 = d0 - (i2 - K1);
vec3 d3 = d0 - 0.5;
vec4 h = max(0.6 - vec4(
dot(d0, d0),
dot(d1, d1),
dot(d2, d2),
dot(d3, d3)
), 0.0);
vec4 n = h * h * h * h * vec4(
dot(d0, hash33(i)),
dot(d1, hash33(i + i1)),
dot(d2, hash33(i + i2)),
dot(d3, hash33(i + 1.0))
);
return dot(vec4(31.316), n);
}
vec4 extractAlpha(vec3 colorIn) {
float a = max(max(colorIn.r, colorIn.g), colorIn.b);
return vec4(colorIn.rgb / (a + 1e-5), a);
}
const vec3 baseColor1 = vec3(0.611765, 0.262745, 0.996078);
const vec3 baseColor2 = vec3(0.298039, 0.760784, 0.913725);
const vec3 baseColor3 = vec3(0.062745, 0.078431, 0.600000);
const float innerRadius = 0.6;
const float noiseScale = 0.65;
float light1(float intensity, float attenuation, float dist) {
return intensity / (1.0 + dist * attenuation);
}
float light2(float intensity, float attenuation, float dist) {
return intensity / (1.0 + dist * dist * attenuation);
}
vec4 draw(vec2 uv) {
vec3 color1 = adjustHue(baseColor1, hue);
vec3 color2 = adjustHue(baseColor2, hue);
vec3 color3 = adjustHue(baseColor3, hue);
float ang = atan(uv.y, uv.x);
float len = length(uv);
float invLen = len > 0.0 ? 1.0 / len : 0.0;
float n0 = snoise3(vec3(uv * noiseScale, iTime * 0.5)) * 0.5 + 0.5;
float r0 = mix(mix(innerRadius, 1.0, 0.4), mix(innerRadius, 1.0, 0.6), n0);
float d0 = distance(uv, (r0 * invLen) * uv);
float v0 = light1(1.0, 10.0, d0);
v0 *= smoothstep(r0 * 1.05, r0, len);
float cl = cos(ang + iTime * 2.0) * 0.5 + 0.5;
float a = iTime * -1.0;
vec2 pos = vec2(cos(a), sin(a)) * r0;
float d = distance(uv, pos);
float v1 = light2(1.5, 5.0, d);
v1 *= light1(1.0, 50.0, d0);
float v2 = smoothstep(1.0, mix(innerRadius, 1.0, n0 * 0.5), len);
float v3 = smoothstep(innerRadius, mix(innerRadius, 1.0, 0.5), len);
vec3 col = mix(color1, color2, cl);
col = mix(color3, col, v0);
col = (col + v1) * v2 * v3;
col = clamp(col, 0.0, 1.0);
return extractAlpha(col);
}
vec4 mainImage(vec2 fragCoord) {
vec2 center = iResolution.xy * 0.5;
float size = min(iResolution.x, iResolution.y);
vec2 uv = (fragCoord - center) / size * 2.0;
float angle = rot;
float s = sin(angle);
float c = cos(angle);
uv = vec2(c * uv.x - s * uv.y, s * uv.x + c * uv.y);
uv.x += hover * hoverIntensity * 0.1 * sin(uv.y * 10.0 + iTime);
uv.y += hover * hoverIntensity * 0.1 * sin(uv.x * 10.0 + iTime);
return draw(uv);
}
void main() {
vec2 fragCoord = vUv * iResolution.xy;
vec4 col = mainImage(fragCoord);
gl_FragColor = vec4(col.rgb * col.a, col.a);
}
`;
useEffect(() => {
const container = ctnDom.current;
if (!container) return;
const renderer = new Renderer({ alpha: true, premultipliedAlpha: false });
const gl = renderer.gl;
gl.clearColor(0, 0, 0, 0);
container.appendChild(gl.canvas);
const geometry = new Triangle(gl);
const program = new Program(gl, {
vertex: vert,
fragment: frag,
uniforms: {
iTime: { value: 0 },
iResolution: {
value: new Vec3(gl.canvas.width, gl.canvas.height, gl.canvas.width / gl.canvas.height)
},
hue: { value: hue },
hover: { value: 0 },
rot: { value: 0 },
hoverIntensity: { value: hoverIntensity }
}
});
const mesh = new Mesh(gl, { geometry, program });
function resize() {
if (!container) return;
const dpr = window.devicePixelRatio || 1;
const width = container.clientWidth;
const height = container.clientHeight;
renderer.setSize(width * dpr, height * dpr);
gl.canvas.style.width = width + 'px';
gl.canvas.style.height = height + 'px';
program.uniforms.iResolution.value.set(gl.canvas.width, gl.canvas.height, gl.canvas.width / gl.canvas.height);
}
window.addEventListener('resize', resize);
resize();
let targetHover = 0;
let lastTime = 0;
let currentRot = 0;
const rotationSpeed = 0.3;
const handleMouseMove = (e) => {
const rect = container.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const width = rect.width;
const height = rect.height;
const size = Math.min(width, height);
const centerX = width / 2;
const centerY = height / 2;
const uvX = ((x - centerX) / size) * 2.0;
const uvY = ((y - centerY) / size) * 2.0;
if (Math.sqrt(uvX * uvX + uvY * uvY) < 0.8) {
targetHover = 1;
} else {
targetHover = 0;
}
};
const handleMouseLeave = () => {
targetHover = 0;
};
container.addEventListener('mousemove', handleMouseMove);
container.addEventListener('mouseleave', handleMouseLeave);
let rafId;
const update = (t) => {
rafId = requestAnimationFrame(update);
const dt = (t - lastTime) * 0.001;
lastTime = t;
program.uniforms.iTime.value = t * 0.001;
program.uniforms.hue.value = hue;
program.uniforms.hoverIntensity.value = hoverIntensity;
const effectiveHover = forceHoverState ? 1 : targetHover;
program.uniforms.hover.value += (effectiveHover - program.uniforms.hover.value) * 0.1;
if (rotateOnHover && effectiveHover > 0.5) {
currentRot += dt * rotationSpeed;
}
program.uniforms.rot.value = currentRot;
renderer.render({ scene: mesh });
};
rafId = requestAnimationFrame(update);
return () => {
cancelAnimationFrame(rafId);
window.removeEventListener('resize', resize);
container.removeEventListener('mousemove', handleMouseMove);
container.removeEventListener('mouseleave', handleMouseLeave);
container.removeChild(gl.canvas);
gl.getExtension('WEBGL_lose_context')?.loseContext();
};
}, [hue, hoverIntensity, rotateOnHover, forceHoverState]);
return <div ref={ctnDom} className="w-full h-full" />;
}
@@ -0,0 +1,987 @@
import { useState, useEffect } from 'react';
import Orb from './Orb';
import ASCIIText from './ASCIIText';
const DUMMY_DATA = {
title: 'Session Memory Processing',
subtitle: 'Compressing conversation context into semantic memories',
memories: [
{
id: 1,
title: 'First Memory',
subtitle: 'Initial context capture',
facts: ['Fact 1', 'Fact 2', 'Fact 3'],
concepts: ['concept1', 'concept2']
},
{
id: 2,
title: 'Second Memory',
subtitle: 'Additional context',
facts: ['Fact A', 'Fact B'],
concepts: ['concept3']
},
{
id: 3,
title: 'Third Memory',
subtitle: 'More context',
facts: ['Fact X', 'Fact Y', 'Fact Z'],
concepts: ['concept4', 'concept5', 'concept6']
}
],
overview: 'This session involved implementing a progressive UI visualization system for memory processing. The user requested a session card component with four distinct states showing the evolution from empty state through memory accumulation to final overview completion.'
};
export default function OverviewCard({
debugMode = true,
initialState = 'empty',
sessionData = null // { overview, memories }
}) {
const [uiState, setUiState] = useState(initialState);
const [orbOpacity, setOrbOpacity] = useState(0);
const [titleOpacity, setTitleOpacity] = useState(0);
const [asciiFontSize, setAsciiFontSize] = useState(64);
const [cardOpacity, setCardOpacity] = useState(0);
const [titlePosition, setTitlePosition] = useState('center'); // 'center' or 'top'
const [visibleMemories, setVisibleMemories] = useState(0);
const [overviewOpacity, setOverviewOpacity] = useState(0);
const [expandedMemoryId, setExpandedMemoryId] = useState(null); // null = show overview, number = show expanded memory
const [selectedSessionId, setSelectedSessionId] = useState(null);
const [sessions, setSessions] = useState([]);
const [loadedSessionData, setLoadedSessionData] = useState(null);
// Use provided sessionData or loaded session data or fallback to dummy data
const data = sessionData || loadedSessionData || DUMMY_DATA;
// Orb parameters
const [orbHue, setOrbHue] = useState(0);
const [orbHoverIntensity, setOrbHoverIntensity] = useState(0.05);
const [orbRotateOnHover, setOrbRotateOnHover] = useState(false);
const [orbForceHoverState, setOrbForceHoverState] = useState(false);
// Load settings from localStorage or use defaults
const loadSetting = (key, defaultValue) => {
const saved = localStorage.getItem(`overviewCard_${key}`);
return saved !== null ? JSON.parse(saved) : defaultValue;
};
// ASCIIText parameters - Title
const [asciiText, setAsciiText] = useState(() => loadSetting('asciiText', DUMMY_DATA.title));
const [asciiTitleFontSize, setAsciiTitleFontSize] = useState(() => loadSetting('asciiTitleFontSize', 12));
const [asciiTitleTextFontSize, setAsciiTitleTextFontSize] = useState(() => loadSetting('asciiTitleTextFontSize', 200));
const [asciiTitleColor, setAsciiTitleColor] = useState(() => loadSetting('asciiTitleColor', '#60a5fa'));
const [asciiTitlePlaneHeight, setAsciiTitlePlaneHeight] = useState(() => loadSetting('asciiTitlePlaneHeight', 8));
const [asciiTitleEnableWaves, setAsciiTitleEnableWaves] = useState(() => loadSetting('asciiTitleEnableWaves', false));
const [asciiTitleEnableMouseRotation, setAsciiTitleEnableMouseRotation] = useState(() => loadSetting('asciiTitleEnableMouseRotation', false));
const [asciiTitleOffsetY, setAsciiTitleOffsetY] = useState(() => loadSetting('asciiTitleOffsetY', 0));
// ASCIIText parameters - Subtitle
const [asciiSubtitle, setAsciiSubtitle] = useState(() => loadSetting('asciiSubtitle', DUMMY_DATA.subtitle));
const [asciiSubtitleFontSize, setAsciiSubtitleFontSize] = useState(() => loadSetting('asciiSubtitleFontSize', 6));
const [asciiSubtitleTextFontSize, setAsciiSubtitleTextFontSize] = useState(() => loadSetting('asciiSubtitleTextFontSize', 120));
const [asciiSubtitleColor, setAsciiSubtitleColor] = useState(() => loadSetting('asciiSubtitleColor', '#60a5fa'));
const [asciiSubtitlePlaneHeight, setAsciiSubtitlePlaneHeight] = useState(() => loadSetting('asciiSubtitlePlaneHeight', 4.8));
const [asciiSubtitleEnableWaves, setAsciiSubtitleEnableWaves] = useState(() => loadSetting('asciiSubtitleEnableWaves', false));
const [asciiSubtitleEnableMouseRotation, setAsciiSubtitleEnableMouseRotation] = useState(() => loadSetting('asciiSubtitleEnableMouseRotation', false));
const [asciiSubtitleOffsetY, setAsciiSubtitleOffsetY] = useState(() => loadSetting('asciiSubtitleOffsetY', 0));
// Debug panel section expansion state
const [sectionsExpanded, setSectionsExpanded] = useState({
animation: true,
orb: false,
asciiTitle: false,
asciiSubtitle: false
});
// Save to localStorage whenever settings change
useEffect(() => {
localStorage.setItem('overviewCard_asciiText', JSON.stringify(asciiText));
}, [asciiText]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiTitleFontSize', JSON.stringify(asciiTitleFontSize));
}, [asciiTitleFontSize]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiTitleTextFontSize', JSON.stringify(asciiTitleTextFontSize));
}, [asciiTitleTextFontSize]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiTitleColor', JSON.stringify(asciiTitleColor));
}, [asciiTitleColor]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiTitlePlaneHeight', JSON.stringify(asciiTitlePlaneHeight));
}, [asciiTitlePlaneHeight]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiTitleEnableWaves', JSON.stringify(asciiTitleEnableWaves));
}, [asciiTitleEnableWaves]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiTitleEnableMouseRotation', JSON.stringify(asciiTitleEnableMouseRotation));
}, [asciiTitleEnableMouseRotation]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiTitleOffsetY', JSON.stringify(asciiTitleOffsetY));
}, [asciiTitleOffsetY]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiSubtitle', JSON.stringify(asciiSubtitle));
}, [asciiSubtitle]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiSubtitleFontSize', JSON.stringify(asciiSubtitleFontSize));
}, [asciiSubtitleFontSize]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiSubtitleTextFontSize', JSON.stringify(asciiSubtitleTextFontSize));
}, [asciiSubtitleTextFontSize]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiSubtitleColor', JSON.stringify(asciiSubtitleColor));
}, [asciiSubtitleColor]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiSubtitlePlaneHeight', JSON.stringify(asciiSubtitlePlaneHeight));
}, [asciiSubtitlePlaneHeight]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiSubtitleEnableWaves', JSON.stringify(asciiSubtitleEnableWaves));
}, [asciiSubtitleEnableWaves]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiSubtitleEnableMouseRotation', JSON.stringify(asciiSubtitleEnableMouseRotation));
}, [asciiSubtitleEnableMouseRotation]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiSubtitleOffsetY', JSON.stringify(asciiSubtitleOffsetY));
}, [asciiSubtitleOffsetY]);
// Fetch available sessions
useEffect(() => {
if (debugMode) {
fetch('http://localhost:3001/api/sessions')
.then(res => res.json())
.then(data => setSessions(data))
.catch(err => console.error('Failed to fetch sessions:', err));
}
}, [debugMode]);
// Load session data when selected
useEffect(() => {
if (selectedSessionId && debugMode) {
fetch(`http://localhost:3001/api/session/${selectedSessionId}`)
.then(res => res.json())
.then(data => {
// Transform data to match expected format
const formattedData = {
title: data.overview?.content?.split('.')[0] || 'Session Overview',
subtitle: data.overview?.content?.substring(0, 100) || '',
overview: data.overview?.content || '',
memories: data.memories || []
};
setLoadedSessionData(formattedData);
// Auto-transition to complete state to show the data
if (data.memories?.length > 0) {
setUiState('complete');
setVisibleMemories(data.memories.length);
}
})
.catch(err => console.error('Failed to fetch session data:', err));
}
}, [selectedSessionId, debugMode]);
// State transition effects
useEffect(() => {
switch (uiState) {
case 'empty':
// Reset everything
setOrbOpacity(0);
setTitleOpacity(0);
setAsciiFontSize(64);
setCardOpacity(0);
setTitlePosition('center');
setVisibleMemories(0);
setOverviewOpacity(0);
setAsciiText(DUMMY_DATA.title);
setAsciiSubtitle(DUMMY_DATA.subtitle);
// Fade in orb and title
setTimeout(() => setOrbOpacity(1), 100);
setTimeout(() => {
setTitleOpacity(1);
// Start animating font size down
let size = 64;
const interval = setInterval(() => {
size -= 2;
if (size <= 12) {
size = 12;
clearInterval(interval);
}
setAsciiFontSize(size);
}, 30);
}, 200);
break;
case 'first-memory':
// Card fades in, title moves to top
setCardOpacity(1);
setTitlePosition('top');
setVisibleMemories(1);
break;
case 'accumulating':
// Show all memories
setVisibleMemories(data.memories?.length || DUMMY_DATA.memories.length);
break;
case 'complete':
// Overview fades in, orb fades out, card becomes solid
setOverviewOpacity(1);
setOrbOpacity(0);
// Make card fully opaque by increasing opacity even more
setCardOpacity(1);
break;
default:
break;
}
}, [uiState]);
return (
<div className="relative w-full min-h-screen">
{/* Debug Controls */}
{debugMode && (
<div className="fixed bottom-4 right-4 z-50 bg-gray-900/95 backdrop-blur-xl border border-gray-700 rounded-xl w-96 max-h-[85vh] flex flex-col">
{/* Header */}
<div className="p-4 border-b border-gray-700">
<h3 className="text-sm font-bold text-blue-400 mb-3">Debug Controls</h3>
{/* Session Selector */}
<div className="mb-3">
<label className="text-xs text-gray-400 mb-1 block">Load Real Session</label>
<select
value={selectedSessionId || ''}
onChange={(e) => setSelectedSessionId(e.target.value || null)}
className="w-full px-2 py-1.5 rounded bg-gray-800 border border-gray-700 text-xs text-gray-300"
>
<option value="">-- Dummy Data --</option>
{sessions.map((session) => (
<option key={session.session_id} value={session.session_id}>
{session.project} - {new Date(session.created_at).toLocaleDateString()}
</option>
))}
</select>
</div>
{/* State Buttons - 2x2 Grid */}
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => setUiState('empty')}
className={`px-2 py-1.5 rounded text-xs font-medium transition-all ${
uiState === 'empty'
? 'bg-blue-500/30 border border-blue-400/60 text-blue-200'
: 'bg-gray-800/50 border border-gray-700 text-gray-300 hover:bg-gray-700/50'
}`}
>
1. Empty
</button>
<button
onClick={() => setUiState('first-memory')}
className={`px-2 py-1.5 rounded text-xs font-medium transition-all ${
uiState === 'first-memory'
? 'bg-blue-500/30 border border-blue-400/60 text-blue-200'
: 'bg-gray-800/50 border border-gray-700 text-gray-300 hover:bg-gray-700/50'
}`}
>
2. First
</button>
<button
onClick={() => setUiState('accumulating')}
className={`px-2 py-1.5 rounded text-xs font-medium transition-all ${
uiState === 'accumulating'
? 'bg-blue-500/30 border border-blue-400/60 text-blue-200'
: 'bg-gray-800/50 border border-gray-700 text-gray-300 hover:bg-gray-700/50'
}`}
>
3. Accum
</button>
<button
onClick={() => setUiState('complete')}
className={`px-2 py-1.5 rounded text-xs font-medium transition-all ${
uiState === 'complete'
? 'bg-blue-500/30 border border-blue-400/60 text-blue-200'
: 'bg-gray-800/50 border border-gray-700 text-gray-300 hover:bg-gray-700/50'
}`}
>
4. Complete
</button>
</div>
</div>
{/* Scrollable Content */}
<div className="overflow-y-auto flex-1 p-4 space-y-2">
{/* Animation State Section */}
<div className="border border-gray-700 rounded-lg overflow-hidden">
<button
onClick={() => setSectionsExpanded(s => ({ ...s, animation: !s.animation }))}
className="w-full px-3 py-2 bg-gray-800/30 hover:bg-gray-800/50 transition-colors flex items-center justify-between text-left"
>
<span className="text-xs font-bold text-purple-400">Animation State</span>
<span className="text-xs text-gray-500">{sectionsExpanded.animation ? '▼' : '▶'}</span>
</button>
{sectionsExpanded.animation && (
<div className="p-3 space-y-2 bg-gray-800/10">
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Orb Opacity</label>
<div className="flex items-center gap-2">
<input
type="range"
min="0"
max="1"
step="0.01"
value={orbOpacity}
onChange={(e) => setOrbOpacity(parseFloat(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{orbOpacity.toFixed(2)}</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Title Opacity</label>
<div className="flex items-center gap-2">
<input
type="range"
min="0"
max="1"
step="0.01"
value={titleOpacity}
onChange={(e) => setTitleOpacity(parseFloat(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{titleOpacity.toFixed(2)}</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Card Opacity</label>
<div className="flex items-center gap-2">
<input
type="range"
min="0"
max="1"
step="0.01"
value={cardOpacity}
onChange={(e) => setCardOpacity(parseFloat(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{cardOpacity.toFixed(2)}</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Overview Opacity</label>
<div className="flex items-center gap-2">
<input
type="range"
min="0"
max="1"
step="0.01"
value={overviewOpacity}
onChange={(e) => setOverviewOpacity(parseFloat(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{overviewOpacity.toFixed(2)}</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Title Position</label>
<select
value={titlePosition}
onChange={(e) => setTitlePosition(e.target.value)}
className="px-2 py-1 rounded bg-gray-800 border border-gray-700 text-xs text-gray-300"
>
<option value="center">Center</option>
<option value="top">Top</option>
</select>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Visible Memories</label>
<div className="flex items-center gap-2">
<input
type="range"
min="0"
max={data.memories?.length || 0}
step="1"
value={visibleMemories}
onChange={(e) => setVisibleMemories(parseInt(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{visibleMemories}/{data.memories?.length || 0}</span>
</div>
</div>
</div>
)}
</div>
{/* Orb Parameters Section */}
<div className="border border-gray-700 rounded-lg overflow-hidden">
<button
onClick={() => setSectionsExpanded(s => ({ ...s, orb: !s.orb }))}
className="w-full px-3 py-2 bg-gray-800/30 hover:bg-gray-800/50 transition-colors flex items-center justify-between text-left"
>
<span className="text-xs font-bold text-blue-400">Orb Parameters</span>
<span className="text-xs text-gray-500">{sectionsExpanded.orb ? '▼' : '▶'}</span>
</button>
{sectionsExpanded.orb && (
<div className="p-3 space-y-2 bg-gray-800/10">
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Hue</label>
<div className="flex items-center gap-2">
<input
type="range"
min="-180"
max="180"
step="1"
value={orbHue}
onChange={(e) => setOrbHue(parseFloat(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{orbHue}°</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Hover Intensity</label>
<div className="flex items-center gap-2">
<input
type="range"
min="0"
max="1"
step="0.01"
value={orbHoverIntensity}
onChange={(e) => setOrbHoverIntensity(parseFloat(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{orbHoverIntensity.toFixed(2)}</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400 flex items-center gap-2">
<input
type="checkbox"
checked={orbRotateOnHover}
onChange={(e) => setOrbRotateOnHover(e.target.checked)}
className="w-4 h-4"
/>
Rotate On Hover
</label>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400 flex items-center gap-2">
<input
type="checkbox"
checked={orbForceHoverState}
onChange={(e) => setOrbForceHoverState(e.target.checked)}
className="w-4 h-4"
/>
Force Hover State
</label>
</div>
</div>
)}
</div>
{/* ASCII Title Parameters Section */}
<div className="border border-gray-700 rounded-lg overflow-hidden">
<button
onClick={() => setSectionsExpanded(s => ({ ...s, asciiTitle: !s.asciiTitle }))}
className="w-full px-3 py-2 bg-gray-800/30 hover:bg-gray-800/50 transition-colors flex items-center justify-between text-left"
>
<span className="text-xs font-bold text-emerald-400">ASCII Title</span>
<span className="text-xs text-gray-500">{sectionsExpanded.asciiTitle ? '▼' : '▶'}</span>
</button>
{sectionsExpanded.asciiTitle && (
<div className="p-3 space-y-2 bg-gray-800/10">
<div>
<label className="text-xs text-gray-400 mb-1 block">Text</label>
<textarea
value={asciiText}
onChange={(e) => setAsciiText(e.target.value)}
rows={2}
className="w-full px-2 py-1 rounded bg-gray-800 border border-gray-700 text-xs text-gray-300 resize-none"
/>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">ASCII Font Size</label>
<div className="flex items-center gap-2">
<input
type="range"
min="4"
max="64"
step="1"
value={asciiTitleFontSize}
onChange={(e) => setAsciiTitleFontSize(parseInt(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{asciiTitleFontSize}px</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Text Font Size</label>
<div className="flex items-center gap-2">
<input
type="range"
min="50"
max="400"
step="10"
value={asciiTitleTextFontSize}
onChange={(e) => setAsciiTitleTextFontSize(parseInt(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{asciiTitleTextFontSize}px</span>
</div>
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Color</label>
<div className="flex gap-2 items-center">
<input
type="color"
value={asciiTitleColor}
onChange={(e) => setAsciiTitleColor(e.target.value)}
className="w-8 h-8 rounded border border-gray-700 bg-gray-800 cursor-pointer"
/>
<input
type="text"
value={asciiTitleColor}
onChange={(e) => setAsciiTitleColor(e.target.value)}
className="flex-1 px-2 py-1 rounded bg-gray-800 border border-gray-700 text-xs text-gray-300"
/>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Plane Height</label>
<div className="flex items-center gap-2">
<input
type="range"
min="1"
max="20"
step="0.5"
value={asciiTitlePlaneHeight}
onChange={(e) => setAsciiTitlePlaneHeight(parseFloat(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{asciiTitlePlaneHeight}</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Y Offset</label>
<div className="flex items-center gap-2">
<input
type="range"
min="-500"
max="500"
step="10"
value={asciiTitleOffsetY}
onChange={(e) => setAsciiTitleOffsetY(parseInt(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{asciiTitleOffsetY}px</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400 flex items-center gap-2">
<input
type="checkbox"
checked={asciiTitleEnableWaves}
onChange={(e) => setAsciiTitleEnableWaves(e.target.checked)}
className="w-4 h-4"
/>
Enable Waves
</label>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400 flex items-center gap-2">
<input
type="checkbox"
checked={asciiTitleEnableMouseRotation}
onChange={(e) => setAsciiTitleEnableMouseRotation(e.target.checked)}
className="w-4 h-4"
/>
Mouse Rotation
</label>
</div>
</div>
)}
</div>
{/* ASCII Subtitle Parameters Section */}
<div className="border border-gray-700 rounded-lg overflow-hidden">
<button
onClick={() => setSectionsExpanded(s => ({ ...s, asciiSubtitle: !s.asciiSubtitle }))}
className="w-full px-3 py-2 bg-gray-800/30 hover:bg-gray-800/50 transition-colors flex items-center justify-between text-left"
>
<span className="text-xs font-bold text-amber-400">ASCII Subtitle</span>
<span className="text-xs text-gray-500">{sectionsExpanded.asciiSubtitle ? '▼' : '▶'}</span>
</button>
{sectionsExpanded.asciiSubtitle && (
<div className="p-3 space-y-2 bg-gray-800/10">
<div>
<label className="text-xs text-gray-400 mb-1 block">Text</label>
<textarea
value={asciiSubtitle}
onChange={(e) => setAsciiSubtitle(e.target.value)}
rows={2}
className="w-full px-2 py-1 rounded bg-gray-800 border border-gray-700 text-xs text-gray-300 resize-none"
/>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">ASCII Font Size</label>
<div className="flex items-center gap-2">
<input
type="range"
min="4"
max="64"
step="1"
value={asciiSubtitleFontSize}
onChange={(e) => setAsciiSubtitleFontSize(parseInt(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{asciiSubtitleFontSize}px</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Text Font Size</label>
<div className="flex items-center gap-2">
<input
type="range"
min="50"
max="400"
step="10"
value={asciiSubtitleTextFontSize}
onChange={(e) => setAsciiSubtitleTextFontSize(parseInt(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{asciiSubtitleTextFontSize}px</span>
</div>
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Color</label>
<div className="flex gap-2 items-center">
<input
type="color"
value={asciiSubtitleColor}
onChange={(e) => setAsciiSubtitleColor(e.target.value)}
className="w-8 h-8 rounded border border-gray-700 bg-gray-800 cursor-pointer"
/>
<input
type="text"
value={asciiSubtitleColor}
onChange={(e) => setAsciiSubtitleColor(e.target.value)}
className="flex-1 px-2 py-1 rounded bg-gray-800 border border-gray-700 text-xs text-gray-300"
/>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Plane Height</label>
<div className="flex items-center gap-2">
<input
type="range"
min="1"
max="20"
step="0.5"
value={asciiSubtitlePlaneHeight}
onChange={(e) => setAsciiSubtitlePlaneHeight(parseFloat(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{asciiSubtitlePlaneHeight}</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Y Offset</label>
<div className="flex items-center gap-2">
<input
type="range"
min="-500"
max="500"
step="10"
value={asciiSubtitleOffsetY}
onChange={(e) => setAsciiSubtitleOffsetY(parseInt(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{asciiSubtitleOffsetY}px</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400 flex items-center gap-2">
<input
type="checkbox"
checked={asciiSubtitleEnableWaves}
onChange={(e) => setAsciiSubtitleEnableWaves(e.target.checked)}
className="w-4 h-4"
/>
Enable Waves
</label>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400 flex items-center gap-2">
<input
type="checkbox"
checked={asciiSubtitleEnableMouseRotation}
onChange={(e) => setAsciiSubtitleEnableMouseRotation(e.target.checked)}
className="w-4 h-4"
/>
Mouse Rotation
</label>
</div>
</div>
)}
</div>
</div>
</div>
)}
{/* Orb Background Overlay */}
<div
className="fixed inset-0 pointer-events-none transition-opacity duration-500"
style={{ opacity: orbOpacity }}
>
<Orb
hue={orbHue}
hoverIntensity={orbHoverIntensity}
rotateOnHover={orbRotateOnHover}
forceHoverState={orbForceHoverState}
/>
</div>
{/* Floating Title (State 1: Empty) */}
{titlePosition === 'center' && (
<div
className="fixed inset-0 flex items-center justify-center pointer-events-none transition-opacity duration-500"
style={{ opacity: titleOpacity }}
>
<div className="relative w-full flex flex-col items-center">
<div
className="relative w-full h-64"
style={{ transform: `translateY(${asciiTitleOffsetY}px)` }}
>
<ASCIIText
text={asciiText}
asciiFontSize={asciiTitleFontSize}
textFontSize={asciiTitleTextFontSize}
textColor={asciiTitleColor}
planeBaseHeight={asciiTitlePlaneHeight}
enableWaves={asciiTitleEnableWaves}
enableMouseRotation={asciiTitleEnableMouseRotation}
/>
</div>
<div
className="relative w-full h-32"
style={{ transform: `translateY(${asciiSubtitleOffsetY}px)` }}
>
<ASCIIText
text={asciiSubtitle}
asciiFontSize={asciiSubtitleFontSize}
textFontSize={asciiSubtitleTextFontSize}
textColor={asciiSubtitleColor}
planeBaseHeight={asciiSubtitlePlaneHeight}
enableWaves={asciiSubtitleEnableWaves}
enableMouseRotation={asciiSubtitleEnableMouseRotation}
/>
</div>
</div>
</div>
)}
{/* Session Card (States 2-4) */}
<div
className="max-w-6xl mx-auto px-4 py-20 transition-opacity duration-500"
style={{ opacity: cardOpacity }}
>
<div className="relative">
{/* Blur background effect */}
<div className="absolute -inset-4 bg-gradient-to-r from-blue-600/20 via-purple-600/20 to-emerald-600/20 rounded-3xl blur-2xl" />
{/* Card with backdrop blur */}
<div
className="relative rounded-3xl p-12 border border-gray-800 transition-all duration-500"
style={{
backgroundColor: uiState === 'complete'
? 'rgba(10, 10, 15, 0.95)'
: 'rgba(10, 10, 15, 0.7)',
backdropFilter: 'blur(20px)'
}}
>
{/* Title at top of card (States 2-4) */}
{titlePosition === 'top' && (
<div className="mb-8">
<h1 className="text-4xl font-black text-transparent bg-clip-text bg-gradient-to-r from-blue-300 via-purple-300 to-emerald-300 mb-4 leading-tight">
{data.title || 'Session Overview'}
</h1>
<p className="text-xl text-gray-400 leading-relaxed">
{data.subtitle || ''}
</p>
</div>
)}
{/* Overview Section (State 4: Complete) */}
{uiState === 'complete' && data.overview && (
<div
className="mb-8 pb-8 border-b border-gray-800 transition-opacity duration-500"
style={{ opacity: overviewOpacity }}
>
<h3 className="text-sm font-bold text-emerald-400 mb-4 flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
SESSION OVERVIEW
</h3>
<p className="text-gray-300 leading-relaxed">
{data.overview}
</p>
</div>
)}
{/* Expanded Memory View */}
{expandedMemoryId !== null && (
<div>
{/* Back Button */}
<button
onClick={() => setExpandedMemoryId(null)}
className="flex items-center gap-2 mb-6 px-4 py-2 rounded-lg bg-gray-800/50 border border-gray-700 text-gray-300 hover:bg-gray-700/50 hover:border-gray-600 transition-all"
>
<span className="text-lg"></span>
<span className="text-sm font-medium">Back to Overview</span>
</button>
{/* Full Memory Card */}
{(() => {
const memory = data.memories?.find(m => m.id === expandedMemoryId);
if (!memory) return null;
return (
<div>
<div className="mb-8">
<h2 className="text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-300 to-purple-300 mb-4">
{memory.title}
</h2>
<p className="text-xl text-gray-400">
{memory.subtitle}
</p>
</div>
{memory.facts && memory.facts.length > 0 && (
<div className="mb-8">
<h3 className="text-sm font-bold text-blue-400 mb-4 flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-blue-400" />
FACTS EXTRACTED
</h3>
<div className="space-y-3">
{memory.facts.map((fact, i) => (
<div key={i} className="flex gap-3 text-gray-300 leading-relaxed">
<span className="text-blue-400 font-mono text-xs mt-1"></span>
<span>{fact}</span>
</div>
))}
</div>
</div>
)}
{memory.concepts && memory.concepts.length > 0 && (
<div>
<h3 className="text-sm font-bold text-purple-400 mb-4 flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-purple-400" />
CONCEPTS
</h3>
<div className="flex flex-wrap gap-2">
{memory.concepts.map((concept, i) => (
<span
key={i}
className="px-3 py-1.5 rounded-lg bg-purple-500/10 border border-purple-400/30 text-purple-300 text-sm font-medium"
>
{concept}
</span>
))}
</div>
</div>
)}
</div>
);
})()}
</div>
)}
{/* Memory Mini-cards (Overview) */}
{expandedMemoryId === null && (
<div className="grid grid-cols-3 gap-4">
{(data.memories || []).slice(0, visibleMemories).map((memory, index) => (
<div
key={memory.id}
onClick={() => setExpandedMemoryId(memory.id)}
className="border border-gray-700/50 rounded-xl p-4 bg-gray-900/30 cursor-pointer hover:bg-gray-800/40 hover:border-gray-600/50 transition-all"
style={{
animation: 'fadeInUp 0.5s ease-out',
animationDelay: `${index * 0.1}s`,
animationFillMode: 'both'
}}
>
<h3 className="text-base font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-300 to-purple-300 mb-2">
{memory.title}
</h3>
<p className="text-xs text-gray-400 line-clamp-2 mb-3">
{memory.subtitle}
</p>
{/* Preview badges */}
<div className="flex gap-2">
{memory.facts && memory.facts.length > 0 && (
<span className="px-2 py-0.5 rounded text-xs bg-blue-500/10 border border-blue-400/30 text-blue-300">
{memory.facts.length} facts
</span>
)}
{memory.concepts && memory.concepts.length > 0 && (
<span className="px-2 py-0.5 rounded text-xs bg-purple-500/10 border border-purple-400/30 text-purple-300">
{memory.concepts.length} concepts
</span>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
<style>{`
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`}</style>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
},
server: {
port: 5173
}
})