ghostjournal 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ghostjournal-0.1.1/CHANGELOG.md +25 -0
- ghostjournal-0.1.1/LICENSE +21 -0
- ghostjournal-0.1.1/MANIFEST.in +7 -0
- ghostjournal-0.1.1/PKG-INFO +209 -0
- ghostjournal-0.1.1/PUBLISHING.md +18 -0
- ghostjournal-0.1.1/README.md +182 -0
- ghostjournal-0.1.1/examples/motoko_sim.py +75 -0
- ghostjournal-0.1.1/ghostjournal/__init__.py +6 -0
- ghostjournal-0.1.1/ghostjournal/__main__.py +3 -0
- ghostjournal-0.1.1/ghostjournal/cli.py +102 -0
- ghostjournal-0.1.1/ghostjournal/embedding.py +48 -0
- ghostjournal-0.1.1/ghostjournal/exceptions.py +10 -0
- ghostjournal-0.1.1/ghostjournal/journal.py +543 -0
- ghostjournal-0.1.1/ghostjournal/models.py +52 -0
- ghostjournal-0.1.1/ghostjournal/schemas/entry-v1.schema.json +165 -0
- ghostjournal-0.1.1/ghostjournal/validation.py +132 -0
- ghostjournal-0.1.1/ghostjournal.egg-info/PKG-INFO +209 -0
- ghostjournal-0.1.1/ghostjournal.egg-info/SOURCES.txt +25 -0
- ghostjournal-0.1.1/ghostjournal.egg-info/dependency_links.txt +1 -0
- ghostjournal-0.1.1/ghostjournal.egg-info/entry_points.txt +2 -0
- ghostjournal-0.1.1/ghostjournal.egg-info/requires.txt +9 -0
- ghostjournal-0.1.1/ghostjournal.egg-info/top_level.txt +1 -0
- ghostjournal-0.1.1/pyproject.toml +47 -0
- ghostjournal-0.1.1/setup.cfg +4 -0
- ghostjournal-0.1.1/tests/test_cli.py +32 -0
- ghostjournal-0.1.1/tests/test_hardening.py +165 -0
- ghostjournal-0.1.1/tests/test_journal.py +123 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
Hardening companion for deeprem 0.1.0. Stop every 0.1.0 writer before upgrading;
|
|
6
|
+
the lock protocol is not mixed-version compatible.
|
|
7
|
+
|
|
8
|
+
- Reject path traversal, separators, glob syntax and unsafe IDs on append/get.
|
|
9
|
+
- Enforce global ID uniqueness across date directories and validate indexed paths.
|
|
10
|
+
- Replace age-stealing lockfiles with OS-held filelock locks; serialize reads,
|
|
11
|
+
reindex and append, and explicitly close SQLite connections.
|
|
12
|
+
- Recover retry keys from original JSON after crash-before-index and rebuild a
|
|
13
|
+
missing index at open. Original payload still wins on a reused client_key.
|
|
14
|
+
- Publish immutable JSON with no-clobber hard links and POSIX file/directory fsync.
|
|
15
|
+
- Reject non-finite JSON and boolean/float schema versions; harden type errors.
|
|
16
|
+
- Reject symlinks inside canonical storage and validate original file layout.
|
|
17
|
+
- Require explicit NN enablement; normal model loading is CPU/cache-only with
|
|
18
|
+
remote-code trust disabled. Add explicit download-model setup command.
|
|
19
|
+
- Emit bounded escaped JSON from prompt_context, retaining its historical-data
|
|
20
|
+
label. This is an output-format change, not a prompt-injection guarantee.
|
|
21
|
+
- Add regression, crash-window, multiprocess and concurrent reindex tests.
|
|
22
|
+
|
|
23
|
+
Valid original entries and old manifest bytes are not migrated or rewritten.
|
|
24
|
+
This release does not itself sign or encrypt a journal. Real NN model loading
|
|
25
|
+
and a non-Linux support matrix still require maintainer testing.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shelleyguitar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ghostjournal
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A local-first, append-only reflective journal substrate for LLM agents.
|
|
5
|
+
Author: Shelleyguitar
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://pypi.org/project/ghostjournal/
|
|
8
|
+
Keywords: agents,journal,memory,embeddings,local-first,llm
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: filelock<4,>=3.16
|
|
20
|
+
Provides-Extra: nn
|
|
21
|
+
Requires-Dist: numpy>=1.24; extra == "nn"
|
|
22
|
+
Requires-Dist: sentence-transformers>=3.0; extra == "nn"
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
25
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# ghostjournal
|
|
29
|
+
|
|
30
|
+
**0.1.1 hardening release.** Stop all 0.1.0 writers before upgrading. The lock
|
|
31
|
+
protocol changed from age-based lockfiles to OS-held advisory locks. Do not mix
|
|
32
|
+
old and new writers on one root. Existing valid entry JSON and existing manifest
|
|
33
|
+
bytes are preserved; new roots receive a journal UUID. Read CHANGELOG.md.
|
|
34
|
+
|
|
35
|
+
`ghostjournal` is boring, local-first infrastructure for agents that need a durable reflective journal rather than a chat-log dump.
|
|
36
|
+
|
|
37
|
+
Each journal entry is immutable JSON. SQLite, FTS, and optional embedding vectors are **derived state**: delete `index/`, run `ghostjournal reindex`, and the searchable journal is rebuilt from `entries/` without rewriting history.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install -e .
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
For local sentence embeddings:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install -e '.[nn]'
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The NN extra uses `sentence-transformers` with
|
|
52
|
+
`sentence-transformers/all-MiniLM-L6-v2` by default. The base runtime dependency is
|
|
53
|
+
`filelock`; SQLite/FTS5 supplies lexical search. NN is now explicitly opt-in even
|
|
54
|
+
when the extra is installed. Ordinary encoder operations use CPU and
|
|
55
|
+
`local_files_only=True`, with `trust_remote_code=False`. Download the selected
|
|
56
|
+
model only with this explicit setup command:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
ghostjournal --root ./journal download-model
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
That command requires the NN extra and network access. Later `--nn` operations
|
|
63
|
+
use the cached model and do not intentionally fetch model files. In strictly
|
|
64
|
+
offline deployments also set `HF_HUB_OFFLINE=1` and `TRANSFORMERS_OFFLINE=1` and
|
|
65
|
+
enforce network policy at the OS boundary. No GPU or API key is required for the
|
|
66
|
+
default public model. Actual model download/inference was not exercised in this
|
|
67
|
+
hardening build; the lexical path and adapter configuration were tested.
|
|
68
|
+
|
|
69
|
+
## API
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from ghostjournal import Journal
|
|
73
|
+
|
|
74
|
+
journal = Journal("./journal", enable_nn=False)
|
|
75
|
+
|
|
76
|
+
entry = journal.append({
|
|
77
|
+
"kind": "pulse",
|
|
78
|
+
"agent": "motoko",
|
|
79
|
+
"voice": "I keep using practical light to explain where work happens.",
|
|
80
|
+
"theme": "light as labor",
|
|
81
|
+
"tags": ["light", "places"],
|
|
82
|
+
"signals": {"focus": "light", "novelty": 0.5, "continuity_refs": []},
|
|
83
|
+
"client_key": "motoko:pulse:2026-09-05T16",
|
|
84
|
+
"meta": {"source_routine": "pulse-4h"},
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
print(journal.get(entry.id).to_dict())
|
|
88
|
+
print(journal.list(kind="pulse", limit=20))
|
|
89
|
+
print(journal.search("what did I care about in lighting?", k=8))
|
|
90
|
+
print(journal.relate(entry.id, k=8))
|
|
91
|
+
print(journal.digest())
|
|
92
|
+
print(journal.prompt_context("what production habits keep returning?", k=6))
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`append()` supplies `id`, timezone-aware UTC `ts`, top-level `schema_version`, `tags`, and `meta.schema_version` when omitted. All supplied fields are validated strictly. Unknown top-level fields are rejected.
|
|
96
|
+
|
|
97
|
+
`client_key` is optional. When reused, `append()` returns the previously stored
|
|
98
|
+
entry, allowing cron retries without duplicate pulses. For compatibility, the
|
|
99
|
+
original payload wins even if a retry supplies different prose. This differs
|
|
100
|
+
from deeprem's strict retry-key conflict rule. All generated IDs are UUIDs.
|
|
101
|
+
Invalid/non-finite JSON values, invalid timestamps, and unsafe IDs are rejected.
|
|
102
|
+
|
|
103
|
+
## CLI
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
ghostjournal --root ./journal init
|
|
107
|
+
|
|
108
|
+
echo '{
|
|
109
|
+
"kind": "pulse",
|
|
110
|
+
"agent": "motoko",
|
|
111
|
+
"voice": "The crane silhouette is becoming a landmark.",
|
|
112
|
+
"theme": "recurring landmarks",
|
|
113
|
+
"tags": ["places", "continuity"],
|
|
114
|
+
"meta": {"source_routine": "pulse-4h"}
|
|
115
|
+
}' | ghostjournal --root ./journal append
|
|
116
|
+
|
|
117
|
+
ghostjournal --root ./journal list --kind pulse --limit 20
|
|
118
|
+
ghostjournal --root ./journal search "recurring places" -k 8
|
|
119
|
+
ghostjournal --root ./journal relate ENTRY_ID -k 8
|
|
120
|
+
ghostjournal --root ./journal digest
|
|
121
|
+
ghostjournal --root ./journal prompt-context "what did I care about last week?" -k 6
|
|
122
|
+
ghostjournal --root ./journal reindex
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Add `--nn` before the subcommand to enable semantic embeddings when `ghostjournal[nn]` is installed:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
ghostjournal --root ./journal --nn search "what identity am I developing?"
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## On-disk layout
|
|
132
|
+
|
|
133
|
+
```text
|
|
134
|
+
journal/
|
|
135
|
+
manifest.json
|
|
136
|
+
entries/
|
|
137
|
+
YYYY/MM/DD/<uuid>.json
|
|
138
|
+
index/
|
|
139
|
+
journal.sqlite3
|
|
140
|
+
models/
|
|
141
|
+
.write.lock
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The JSON entry files are canonical. SQLite contains metadata, FTS content, idempotency keys, and—when enabled—float32 embedding blobs. Keeping vectors in SQLite avoids an additional vector database and makes the derived index transactional and simple to rebuild. `models/` is reserved for encoder/cache integrations; model caching itself follows the sentence-transformers/Hugging Face cache configuration.
|
|
145
|
+
|
|
146
|
+
Reads, writes, and reindex are serialized with an OS-held advisory lock. No live
|
|
147
|
+
lock is stolen based on age. Entry files are flushed/fsynced and published with
|
|
148
|
+
an atomic no-clobber hard link; newly created parent directories are synced on
|
|
149
|
+
POSIX. SQLite connections are explicitly closed. The journal's canonical files,
|
|
150
|
+
not a stale index, determine ID uniqueness and retry keys. An exact retry after
|
|
151
|
+
a crash between file publication and indexing repairs the index. Digests remain
|
|
152
|
+
computed views unless the caller explicitly appends a new `kind="digest"` entry.
|
|
153
|
+
|
|
154
|
+
Use a local filesystem supporting OS locks, hard links and atomic replacement.
|
|
155
|
+
Network filesystems and hostile same-account processes are not a supported
|
|
156
|
+
security boundary. This package is not encrypted or cryptographically signed;
|
|
157
|
+
use deeprem for evidence seals and review decisions, and encrypted storage for
|
|
158
|
+
source-journal confidentiality. Symlink entry paths and path/glob syntax in IDs
|
|
159
|
+
are rejected. `prompt_context()` now emits bounded, escaped historical JSON data,
|
|
160
|
+
not a privileged instruction block. It is not a complete prompt-injection defense.
|
|
161
|
+
|
|
162
|
+
## Entry schema v1
|
|
163
|
+
|
|
164
|
+
The packaged JSON Schema lives at `ghostjournal/schemas/entry-v1.schema.json`.
|
|
165
|
+
|
|
166
|
+
Core fields:
|
|
167
|
+
|
|
168
|
+
- `schema_version`: `1`
|
|
169
|
+
- `id`: UUID by default; custom IDs must match `[A-Za-z0-9][A-Za-z0-9_-]{0,127}`
|
|
170
|
+
- `ts`: timezone-aware ISO8601 timestamp
|
|
171
|
+
- `kind`: `pulse | evening | note | digest`
|
|
172
|
+
- `agent`: agent identity
|
|
173
|
+
- `voice`: reflective prose
|
|
174
|
+
- `theme`: short theme label
|
|
175
|
+
- `tags`: unique strings
|
|
176
|
+
- `mood`: optional short string
|
|
177
|
+
- `anchors`: optional `{type, ref, label?}` external references; ghostjournal never fetches them
|
|
178
|
+
- `signals`: optional `focus`, `novelty`, and `continuity_refs`
|
|
179
|
+
- `embedding`: optional numeric vector; normally embeddings are stored separately in the index
|
|
180
|
+
- `client_key`: optional idempotency key
|
|
181
|
+
- `meta`: extensible metadata object with required `schema_version` and optional `source_routine`
|
|
182
|
+
|
|
183
|
+
## Why structured JSON helps a “ghost” develop
|
|
184
|
+
|
|
185
|
+
Raw markdown preserves prose but forces every later agent run to rediscover what the prose means. A ghostjournal entry preserves both levels at once: `voice` keeps the subjective record, while stable machine fields expose theme, focus, novelty, provenance, tags, and explicit continuity links.
|
|
186
|
+
|
|
187
|
+
That gives retrieval more than a transcript. An agent can ask for semantically similar past thoughts, restrict by entry kind or time, aggregate recurring themes, and carry compact “past-you” context into its next reflective prompt. The library does not claim to create identity and does not call an LLM; it makes the agent’s self-observations durable, addressable, and comparable over time.
|
|
188
|
+
|
|
189
|
+
## Recovery contract
|
|
190
|
+
|
|
191
|
+
The `entries/` tree is the source of truth. With writers stopped, remove the entire `index/` directory and run:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
ghostjournal --root ./journal reindex
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
All searchable metadata and optional vectors are recreated solely from immutable entry JSON. Historical JSON files are not modified. Opening an existing journal with a missing index also rebuilds it automatically. NN reindex requires the selected encoder to already be cached.
|
|
198
|
+
|
|
199
|
+
## Development
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
python -m pytest
|
|
203
|
+
python examples/motoko_sim.py
|
|
204
|
+
python -m build
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## License
|
|
208
|
+
|
|
209
|
+
MIT
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Publishing ghostjournal 0.1.1
|
|
2
|
+
|
|
3
|
+
This is the patched dependency for deeprem 0.1.0. Publish it first. No upload has
|
|
4
|
+
been performed. Verify project ownership, name, licensing and metadata before
|
|
5
|
+
public release. Review the accompanying deeprem PUBLISHING.md and release-kit
|
|
6
|
+
verification report for exact checks and remaining release gates.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
python -m pip install -e '.[dev]'
|
|
10
|
+
python -m pytest
|
|
11
|
+
python -m build
|
|
12
|
+
python -m twine check --strict dist/*
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Prefer owner-controlled PyPI Trusted Publishing. Expected files are
|
|
16
|
+
`ghostjournal-0.1.1-py3-none-any.whl` and `ghostjournal-0.1.1.tar.gz`. Do not upload
|
|
17
|
+
the old 0.1.0 artifacts as the hardened version. Stop old writers before any
|
|
18
|
+
runtime upgrade, since 0.1.0 and 0.1.1 locking protocols differ.
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# ghostjournal
|
|
2
|
+
|
|
3
|
+
**0.1.1 hardening release.** Stop all 0.1.0 writers before upgrading. The lock
|
|
4
|
+
protocol changed from age-based lockfiles to OS-held advisory locks. Do not mix
|
|
5
|
+
old and new writers on one root. Existing valid entry JSON and existing manifest
|
|
6
|
+
bytes are preserved; new roots receive a journal UUID. Read CHANGELOG.md.
|
|
7
|
+
|
|
8
|
+
`ghostjournal` is boring, local-first infrastructure for agents that need a durable reflective journal rather than a chat-log dump.
|
|
9
|
+
|
|
10
|
+
Each journal entry is immutable JSON. SQLite, FTS, and optional embedding vectors are **derived state**: delete `index/`, run `ghostjournal reindex`, and the searchable journal is rebuilt from `entries/` without rewriting history.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install -e .
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
For local sentence embeddings:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install -e '.[nn]'
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The NN extra uses `sentence-transformers` with
|
|
25
|
+
`sentence-transformers/all-MiniLM-L6-v2` by default. The base runtime dependency is
|
|
26
|
+
`filelock`; SQLite/FTS5 supplies lexical search. NN is now explicitly opt-in even
|
|
27
|
+
when the extra is installed. Ordinary encoder operations use CPU and
|
|
28
|
+
`local_files_only=True`, with `trust_remote_code=False`. Download the selected
|
|
29
|
+
model only with this explicit setup command:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
ghostjournal --root ./journal download-model
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
That command requires the NN extra and network access. Later `--nn` operations
|
|
36
|
+
use the cached model and do not intentionally fetch model files. In strictly
|
|
37
|
+
offline deployments also set `HF_HUB_OFFLINE=1` and `TRANSFORMERS_OFFLINE=1` and
|
|
38
|
+
enforce network policy at the OS boundary. No GPU or API key is required for the
|
|
39
|
+
default public model. Actual model download/inference was not exercised in this
|
|
40
|
+
hardening build; the lexical path and adapter configuration were tested.
|
|
41
|
+
|
|
42
|
+
## API
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from ghostjournal import Journal
|
|
46
|
+
|
|
47
|
+
journal = Journal("./journal", enable_nn=False)
|
|
48
|
+
|
|
49
|
+
entry = journal.append({
|
|
50
|
+
"kind": "pulse",
|
|
51
|
+
"agent": "motoko",
|
|
52
|
+
"voice": "I keep using practical light to explain where work happens.",
|
|
53
|
+
"theme": "light as labor",
|
|
54
|
+
"tags": ["light", "places"],
|
|
55
|
+
"signals": {"focus": "light", "novelty": 0.5, "continuity_refs": []},
|
|
56
|
+
"client_key": "motoko:pulse:2026-09-05T16",
|
|
57
|
+
"meta": {"source_routine": "pulse-4h"},
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
print(journal.get(entry.id).to_dict())
|
|
61
|
+
print(journal.list(kind="pulse", limit=20))
|
|
62
|
+
print(journal.search("what did I care about in lighting?", k=8))
|
|
63
|
+
print(journal.relate(entry.id, k=8))
|
|
64
|
+
print(journal.digest())
|
|
65
|
+
print(journal.prompt_context("what production habits keep returning?", k=6))
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`append()` supplies `id`, timezone-aware UTC `ts`, top-level `schema_version`, `tags`, and `meta.schema_version` when omitted. All supplied fields are validated strictly. Unknown top-level fields are rejected.
|
|
69
|
+
|
|
70
|
+
`client_key` is optional. When reused, `append()` returns the previously stored
|
|
71
|
+
entry, allowing cron retries without duplicate pulses. For compatibility, the
|
|
72
|
+
original payload wins even if a retry supplies different prose. This differs
|
|
73
|
+
from deeprem's strict retry-key conflict rule. All generated IDs are UUIDs.
|
|
74
|
+
Invalid/non-finite JSON values, invalid timestamps, and unsafe IDs are rejected.
|
|
75
|
+
|
|
76
|
+
## CLI
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
ghostjournal --root ./journal init
|
|
80
|
+
|
|
81
|
+
echo '{
|
|
82
|
+
"kind": "pulse",
|
|
83
|
+
"agent": "motoko",
|
|
84
|
+
"voice": "The crane silhouette is becoming a landmark.",
|
|
85
|
+
"theme": "recurring landmarks",
|
|
86
|
+
"tags": ["places", "continuity"],
|
|
87
|
+
"meta": {"source_routine": "pulse-4h"}
|
|
88
|
+
}' | ghostjournal --root ./journal append
|
|
89
|
+
|
|
90
|
+
ghostjournal --root ./journal list --kind pulse --limit 20
|
|
91
|
+
ghostjournal --root ./journal search "recurring places" -k 8
|
|
92
|
+
ghostjournal --root ./journal relate ENTRY_ID -k 8
|
|
93
|
+
ghostjournal --root ./journal digest
|
|
94
|
+
ghostjournal --root ./journal prompt-context "what did I care about last week?" -k 6
|
|
95
|
+
ghostjournal --root ./journal reindex
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Add `--nn` before the subcommand to enable semantic embeddings when `ghostjournal[nn]` is installed:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
ghostjournal --root ./journal --nn search "what identity am I developing?"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## On-disk layout
|
|
105
|
+
|
|
106
|
+
```text
|
|
107
|
+
journal/
|
|
108
|
+
manifest.json
|
|
109
|
+
entries/
|
|
110
|
+
YYYY/MM/DD/<uuid>.json
|
|
111
|
+
index/
|
|
112
|
+
journal.sqlite3
|
|
113
|
+
models/
|
|
114
|
+
.write.lock
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The JSON entry files are canonical. SQLite contains metadata, FTS content, idempotency keys, and—when enabled—float32 embedding blobs. Keeping vectors in SQLite avoids an additional vector database and makes the derived index transactional and simple to rebuild. `models/` is reserved for encoder/cache integrations; model caching itself follows the sentence-transformers/Hugging Face cache configuration.
|
|
118
|
+
|
|
119
|
+
Reads, writes, and reindex are serialized with an OS-held advisory lock. No live
|
|
120
|
+
lock is stolen based on age. Entry files are flushed/fsynced and published with
|
|
121
|
+
an atomic no-clobber hard link; newly created parent directories are synced on
|
|
122
|
+
POSIX. SQLite connections are explicitly closed. The journal's canonical files,
|
|
123
|
+
not a stale index, determine ID uniqueness and retry keys. An exact retry after
|
|
124
|
+
a crash between file publication and indexing repairs the index. Digests remain
|
|
125
|
+
computed views unless the caller explicitly appends a new `kind="digest"` entry.
|
|
126
|
+
|
|
127
|
+
Use a local filesystem supporting OS locks, hard links and atomic replacement.
|
|
128
|
+
Network filesystems and hostile same-account processes are not a supported
|
|
129
|
+
security boundary. This package is not encrypted or cryptographically signed;
|
|
130
|
+
use deeprem for evidence seals and review decisions, and encrypted storage for
|
|
131
|
+
source-journal confidentiality. Symlink entry paths and path/glob syntax in IDs
|
|
132
|
+
are rejected. `prompt_context()` now emits bounded, escaped historical JSON data,
|
|
133
|
+
not a privileged instruction block. It is not a complete prompt-injection defense.
|
|
134
|
+
|
|
135
|
+
## Entry schema v1
|
|
136
|
+
|
|
137
|
+
The packaged JSON Schema lives at `ghostjournal/schemas/entry-v1.schema.json`.
|
|
138
|
+
|
|
139
|
+
Core fields:
|
|
140
|
+
|
|
141
|
+
- `schema_version`: `1`
|
|
142
|
+
- `id`: UUID by default; custom IDs must match `[A-Za-z0-9][A-Za-z0-9_-]{0,127}`
|
|
143
|
+
- `ts`: timezone-aware ISO8601 timestamp
|
|
144
|
+
- `kind`: `pulse | evening | note | digest`
|
|
145
|
+
- `agent`: agent identity
|
|
146
|
+
- `voice`: reflective prose
|
|
147
|
+
- `theme`: short theme label
|
|
148
|
+
- `tags`: unique strings
|
|
149
|
+
- `mood`: optional short string
|
|
150
|
+
- `anchors`: optional `{type, ref, label?}` external references; ghostjournal never fetches them
|
|
151
|
+
- `signals`: optional `focus`, `novelty`, and `continuity_refs`
|
|
152
|
+
- `embedding`: optional numeric vector; normally embeddings are stored separately in the index
|
|
153
|
+
- `client_key`: optional idempotency key
|
|
154
|
+
- `meta`: extensible metadata object with required `schema_version` and optional `source_routine`
|
|
155
|
+
|
|
156
|
+
## Why structured JSON helps a “ghost” develop
|
|
157
|
+
|
|
158
|
+
Raw markdown preserves prose but forces every later agent run to rediscover what the prose means. A ghostjournal entry preserves both levels at once: `voice` keeps the subjective record, while stable machine fields expose theme, focus, novelty, provenance, tags, and explicit continuity links.
|
|
159
|
+
|
|
160
|
+
That gives retrieval more than a transcript. An agent can ask for semantically similar past thoughts, restrict by entry kind or time, aggregate recurring themes, and carry compact “past-you” context into its next reflective prompt. The library does not claim to create identity and does not call an LLM; it makes the agent’s self-observations durable, addressable, and comparable over time.
|
|
161
|
+
|
|
162
|
+
## Recovery contract
|
|
163
|
+
|
|
164
|
+
The `entries/` tree is the source of truth. With writers stopped, remove the entire `index/` directory and run:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
ghostjournal --root ./journal reindex
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
All searchable metadata and optional vectors are recreated solely from immutable entry JSON. Historical JSON files are not modified. Opening an existing journal with a missing index also rebuilds it automatically. NN reindex requires the selected encoder to already be cached.
|
|
171
|
+
|
|
172
|
+
## Development
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
python -m pytest
|
|
176
|
+
python examples/motoko_sim.py
|
|
177
|
+
python -m build
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## License
|
|
181
|
+
|
|
182
|
+
MIT
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from tempfile import TemporaryDirectory
|
|
5
|
+
|
|
6
|
+
from ghostjournal import Journal
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> None:
|
|
10
|
+
with TemporaryDirectory(prefix="motoko-journal-") as tmp:
|
|
11
|
+
journal = Journal(Path(tmp), enable_nn=False)
|
|
12
|
+
|
|
13
|
+
pulses = [
|
|
14
|
+
{
|
|
15
|
+
"kind": "pulse",
|
|
16
|
+
"agent": "motoko",
|
|
17
|
+
"voice": "I keep favoring pools of practical light over decorative glow. The dock feels more believable when light explains the work.",
|
|
18
|
+
"theme": "light as labor",
|
|
19
|
+
"tags": ["light", "places"],
|
|
20
|
+
"mood": "curious",
|
|
21
|
+
"signals": {"focus": "light", "novelty": 0.55, "continuity_refs": []},
|
|
22
|
+
"client_key": "motoko:pulse:1",
|
|
23
|
+
"meta": {"source_routine": "pulse-4h"},
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"kind": "pulse",
|
|
27
|
+
"agent": "motoko",
|
|
28
|
+
"voice": "Mechanical detail reads better when I imply repeated maintenance instead of drawing every bolt.",
|
|
29
|
+
"theme": "economical machinery",
|
|
30
|
+
"tags": ["process", "effects"],
|
|
31
|
+
"mood": "focused",
|
|
32
|
+
"signals": {"focus": "process", "novelty": 0.35, "continuity_refs": []},
|
|
33
|
+
"client_key": "motoko:pulse:2",
|
|
34
|
+
"meta": {"source_routine": "pulse-4h"},
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"kind": "pulse",
|
|
38
|
+
"agent": "motoko",
|
|
39
|
+
"voice": "The same crane silhouette keeps returning. It may be turning into a landmark rather than a prop.",
|
|
40
|
+
"theme": "recurring landmarks",
|
|
41
|
+
"tags": ["places", "continuity"],
|
|
42
|
+
"mood": "attentive",
|
|
43
|
+
"signals": {"focus": "places", "novelty": 0.7, "continuity_refs": []},
|
|
44
|
+
"client_key": "motoko:pulse:3",
|
|
45
|
+
"meta": {"source_routine": "pulse-4h"},
|
|
46
|
+
},
|
|
47
|
+
]
|
|
48
|
+
saved = [journal.append(item) for item in pulses]
|
|
49
|
+
|
|
50
|
+
journal.append({
|
|
51
|
+
"kind": "evening",
|
|
52
|
+
"agent": "motoko",
|
|
53
|
+
"voice": "Today I trusted recurring shapes more than novelty. Light, maintenance, and the crane all became ways of showing that a place has habits. I want tomorrow's work to keep that restraint.",
|
|
54
|
+
"theme": "places have habits",
|
|
55
|
+
"tags": ["continuity", "light", "process"],
|
|
56
|
+
"mood": "settled",
|
|
57
|
+
"signals": {
|
|
58
|
+
"focus": "self",
|
|
59
|
+
"novelty": 0.45,
|
|
60
|
+
"continuity_refs": [entry.id for entry in saved],
|
|
61
|
+
},
|
|
62
|
+
"client_key": "motoko:evening:1",
|
|
63
|
+
"meta": {"source_routine": "evening-journal"},
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
print("SEARCH")
|
|
67
|
+
for hit in journal.search("what matters about light and recurring places?", k=3):
|
|
68
|
+
print(f"{hit.score:.3f} {hit.entry_id} {hit.snippet}")
|
|
69
|
+
|
|
70
|
+
print("\nPROMPT CONTEXT")
|
|
71
|
+
print(journal.prompt_context("what production identity am I developing?", k=4))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
main()
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
from .exceptions import EntryNotFound, GhostJournalError, ValidationError
|
|
2
|
+
from .journal import Journal
|
|
3
|
+
from .models import Digest, Entry, Hit
|
|
4
|
+
|
|
5
|
+
__all__ = ["Journal", "Entry", "Hit", "Digest", "GhostJournalError", "ValidationError", "EntryNotFound"]
|
|
6
|
+
__version__ = "0.1.1"
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .journal import Journal
|
|
10
|
+
from .embedding import DEFAULT_MODEL, download_model
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _dump(value: Any) -> None:
|
|
14
|
+
if hasattr(value, "to_dict"):
|
|
15
|
+
value = value.to_dict()
|
|
16
|
+
print(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _entry_dict(entry):
|
|
20
|
+
return entry.to_dict()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
24
|
+
parser = argparse.ArgumentParser(prog="ghostjournal", description="Local-first journal substrate for agents")
|
|
25
|
+
parser.add_argument("--root", default="journal", help="journal root directory (default: ./journal)")
|
|
26
|
+
parser.add_argument("--nn", action="store_true", help="enable local sentence embeddings (requires ghostjournal[nn])")
|
|
27
|
+
parser.add_argument("--model", default=DEFAULT_MODEL, help="local sentence-transformer model name")
|
|
28
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
29
|
+
|
|
30
|
+
sub.add_parser("init", help="create/open a journal root")
|
|
31
|
+
sub.add_parser("download-model", help="explicit network opt-in to cache the local encoder")
|
|
32
|
+
|
|
33
|
+
ap = sub.add_parser("append", help="append one JSON entry")
|
|
34
|
+
ap.add_argument("--json", dest="json_text", help="entry JSON; if omitted, read JSON from stdin")
|
|
35
|
+
|
|
36
|
+
gp = sub.add_parser("get", help="get an entry by id")
|
|
37
|
+
gp.add_argument("id")
|
|
38
|
+
|
|
39
|
+
lp = sub.add_parser("list", help="list entries")
|
|
40
|
+
lp.add_argument("--since")
|
|
41
|
+
lp.add_argument("--until")
|
|
42
|
+
lp.add_argument("--kind", choices=["pulse", "evening", "note", "digest"])
|
|
43
|
+
lp.add_argument("--limit", type=int, default=100)
|
|
44
|
+
|
|
45
|
+
sp = sub.add_parser("search", help="hybrid lexical/semantic search")
|
|
46
|
+
sp.add_argument("query")
|
|
47
|
+
sp.add_argument("-k", type=int, default=8)
|
|
48
|
+
|
|
49
|
+
rp = sub.add_parser("relate", help="find related entries")
|
|
50
|
+
rp.add_argument("id")
|
|
51
|
+
rp.add_argument("-k", type=int, default=8)
|
|
52
|
+
|
|
53
|
+
dp = sub.add_parser("digest", help="roll up themes/tags/mood/focus")
|
|
54
|
+
dp.add_argument("--since")
|
|
55
|
+
dp.add_argument("--until")
|
|
56
|
+
|
|
57
|
+
pc = sub.add_parser("prompt-context", help="render compact past-self context")
|
|
58
|
+
pc.add_argument("query")
|
|
59
|
+
pc.add_argument("-k", type=int, default=6)
|
|
60
|
+
pc.add_argument("--max-chars", type=int, default=6000)
|
|
61
|
+
|
|
62
|
+
sub.add_parser("reindex", help="rebuild all derived indexes from immutable entry JSON")
|
|
63
|
+
return parser
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def main(argv: list[str] | None = None) -> int:
|
|
67
|
+
args = build_parser().parse_args(argv)
|
|
68
|
+
journal = Journal(Path(args.root), enable_nn=args.nn, model_name=args.model)
|
|
69
|
+
|
|
70
|
+
if args.command == "download-model":
|
|
71
|
+
download_model(args.model, cache_folder=str(journal.models_dir))
|
|
72
|
+
_dump({"model": args.model, "cache": str(journal.models_dir)})
|
|
73
|
+
elif args.command == "init":
|
|
74
|
+
_dump({"root": str(journal.root), "manifest": str(journal.manifest_path)})
|
|
75
|
+
elif args.command == "append":
|
|
76
|
+
raw = args.json_text if args.json_text is not None else sys.stdin.read()
|
|
77
|
+
_dump(journal.append(json.loads(raw)))
|
|
78
|
+
elif args.command == "get":
|
|
79
|
+
_dump(journal.get(args.id))
|
|
80
|
+
elif args.command == "list":
|
|
81
|
+
_dump([_entry_dict(e) for e in journal.list(since=args.since, until=args.until, kind=args.kind, limit=args.limit)])
|
|
82
|
+
elif args.command == "search":
|
|
83
|
+
_dump([
|
|
84
|
+
{"entry_id": h.entry_id, "score": h.score, "lexical_score": h.lexical_score, "semantic_score": h.semantic_score, "snippet": h.snippet}
|
|
85
|
+
for h in journal.search(args.query, k=args.k)
|
|
86
|
+
])
|
|
87
|
+
elif args.command == "relate":
|
|
88
|
+
_dump([
|
|
89
|
+
{"entry_id": h.entry_id, "score": h.score, "snippet": h.snippet}
|
|
90
|
+
for h in journal.relate(args.id, k=args.k)
|
|
91
|
+
])
|
|
92
|
+
elif args.command == "digest":
|
|
93
|
+
_dump(journal.digest(since=args.since, until=args.until))
|
|
94
|
+
elif args.command == "prompt-context":
|
|
95
|
+
print(journal.prompt_context(args.query, k=args.k, max_chars=args.max_chars))
|
|
96
|
+
elif args.command == "reindex":
|
|
97
|
+
_dump({"indexed": journal.reindex()})
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
raise SystemExit(main())
|