pal-code 0.1.0__py3-none-any.whl
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.
- pal/__init__.py +29 -0
- pal/__main__.py +3 -0
- pal/access_manager/__init__.py +16 -0
- pal/access_manager/components/__init__.py +14 -0
- pal/access_manager/components/access_control.py +41 -0
- pal/access_manager/components/base.py +39 -0
- pal/access_manager/components/intervention.py +31 -0
- pal/access_manager/components/privilege_registry.py +30 -0
- pal/access_manager/pipeline.py +68 -0
- pal/access_manager/utils/__init__.py +2 -0
- pal/agent_constraints/__init__.py +3 -0
- pal/agent_constraints/components/AGENT_OUTPUT.md +13 -0
- pal/agent_constraints/components/__init__.py +3 -0
- pal/agent_constraints/components/constraints.py +22 -0
- pal/agent_constraints/pipeline.py +22 -0
- pal/agent_constraints/utils/__init__.py +2 -0
- pal/agents/__init__.py +12 -0
- pal/agents/coding.py +14 -0
- pal/agents/docs.py +15 -0
- pal/chat/__init__.py +4 -0
- pal/chat/demo.py +231 -0
- pal/chat/providers.py +23 -0
- pal/chat/service.py +591 -0
- pal/chat/services/__init__.py +26 -0
- pal/chat/services/base.py +142 -0
- pal/chat/services/data.py +142 -0
- pal/chat/services/media.py +123 -0
- pal/chat/services/web.py +60 -0
- pal/chat/sessions.py +108 -0
- pal/chat/tools.py +446 -0
- pal/chat/uploads.py +81 -0
- pal/cli.py +502 -0
- pal/compression/__init__.py +12 -0
- pal/compression/components/__init__.py +64 -0
- pal/compression/components/adverbs.py +20 -0
- pal/compression/components/hedges.py +54 -0
- pal/compression/components/interjections.py +20 -0
- pal/compression/components/minifier.py +56 -0
- pal/compression/components/output_compressor.py +13 -0
- pal/compression/components/pleonasms.py +54 -0
- pal/compression/components/punctuations.py +36 -0
- pal/compression/components/stopwords.py +62 -0
- pal/compression/history.py +346 -0
- pal/compression/pipeline.py +58 -0
- pal/compression/utils/__init__.py +30 -0
- pal/compression/utils/negations.py +18 -0
- pal/compression/utils/nltk_setup.py +20 -0
- pal/compression/utils/pronouns.py +27 -0
- pal/compression/utils/proselint_setup.py +9 -0
- pal/compression/utils/spacy_setup.py +22 -0
- pal/config/default.yaml +163 -0
- pal/config.py +504 -0
- pal/context_manager/__init__.py +7 -0
- pal/context_manager/chat_context.py +117 -0
- pal/context_manager/components/__init__.py +5 -0
- pal/context_manager/components/assembler.py +75 -0
- pal/context_manager/components/context_cache.py +45 -0
- pal/context_manager/components/kv_bridge.py +16 -0
- pal/context_manager/components/pager.py +48 -0
- pal/context_manager/components/simple_context_manager.py +56 -0
- pal/context_manager/components/turns.py +88 -0
- pal/context_manager/pipeline.py +27 -0
- pal/context_manager/utils/__init__.py +18 -0
- pal/context_manager/utils/hashing.py +5 -0
- pal/context_manager/utils/relevance.py +59 -0
- pal/context_manager/utils/storage.py +37 -0
- pal/documents/__init__.py +4 -0
- pal/documents/components/__init__.py +0 -0
- pal/documents/components/chunker.py +79 -0
- pal/documents/components/index.py +155 -0
- pal/documents/utils/__init__.py +1 -0
- pal/errors.py +40 -0
- pal/input_converters/__init__.py +8 -0
- pal/input_converters/components/__init__.py +0 -0
- pal/input_converters/components/base.py +38 -0
- pal/input_converters/components/pandoc_converter.py +87 -0
- pal/input_converters/components/pdf_converter.py +46 -0
- pal/input_converters/components/pptx_converter.py +52 -0
- pal/input_converters/components/xlsx_converter.py +38 -0
- pal/input_converters/pipeline.py +72 -0
- pal/input_converters/utils/__init__.py +1 -0
- pal/input_converters/workspace.py +56 -0
- pal/input_savers/INPUT_RULES.md +2 -0
- pal/input_savers/__init__.py +5 -0
- pal/input_savers/components/__init__.py +0 -0
- pal/input_savers/components/base.py +40 -0
- pal/input_savers/components/methods.py +41 -0
- pal/input_savers/components/verifier.py +39 -0
- pal/input_savers/pipeline.py +154 -0
- pal/input_savers/utils/__init__.py +0 -0
- pal/input_savers/utils/guards.py +94 -0
- pal/knowledge_graph/__init__.py +10 -0
- pal/knowledge_graph/components/__init__.py +0 -0
- pal/knowledge_graph/components/base.py +50 -0
- pal/knowledge_graph/components/builder.py +54 -0
- pal/knowledge_graph/components/retriever.py +100 -0
- pal/knowledge_graph/components/workspace.py +116 -0
- pal/knowledge_graph/pipeline.py +215 -0
- pal/knowledge_graph/utils/__init__.py +1 -0
- pal/latency_tracker/__init__.py +3 -0
- pal/latency_tracker/pipeline.py +86 -0
- pal/llm_scheduler/__init__.py +11 -0
- pal/llm_scheduler/api.py +137 -0
- pal/llm_scheduler/code.py +28 -0
- pal/llm_scheduler/core.py +92 -0
- pal/llm_scheduler/local.py +215 -0
- pal/llm_scheduler/pipeline.py +42 -0
- pal/llm_scheduler/types.py +92 -0
- pal/llm_wrapper/__init__.py +34 -0
- pal/llm_wrapper/adapter.py +316 -0
- pal/llm_wrapper/components/__init__.py +23 -0
- pal/llm_wrapper/components/adapter_types.py +90 -0
- pal/llm_wrapper/components/base.py +195 -0
- pal/llm_wrapper/components/features.py +104 -0
- pal/llm_wrapper/components/litellm_wrapper.py +215 -0
- pal/llm_wrapper/components/openrouter_wrapper.py +114 -0
- pal/llm_wrapper/modes.py +73 -0
- pal/llm_wrapper/pipeline.py +61 -0
- pal/llm_wrapper/utils/__init__.py +4 -0
- pal/llm_wrapper/utils/registry.py +49 -0
- pal/llm_wrapper/utils/token_counting.py +10 -0
- pal/local_llm/__init__.py +5 -0
- pal/local_llm/components/__init__.py +0 -0
- pal/local_llm/components/huggingface_local.py +217 -0
- pal/local_llm/components/local_llm.py +60 -0
- pal/logging_setup.py +18 -0
- pal/memory/__init__.py +7 -0
- pal/memory/components/__init__.py +6 -0
- pal/memory/components/lru_k.py +56 -0
- pal/memory/components/manager.py +98 -0
- pal/memory/components/note.py +54 -0
- pal/memory/components/storage_manager.py +60 -0
- pal/memory/pipeline.py +122 -0
- pal/memory/utils/__init__.py +3 -0
- pal/memory/utils/keyword_overlap.py +3 -0
- pal/memory_tracker/__init__.py +3 -0
- pal/memory_tracker/components/__init__.py +0 -0
- pal/memory_tracker/components/process.py +56 -0
- pal/memory_tracker/pipeline.py +44 -0
- pal/metrics/__init__.py +4 -0
- pal/metrics/components/__init__.py +4 -0
- pal/metrics/components/collector.py +140 -0
- pal/metrics/components/exporter.py +28 -0
- pal/metrics/pipeline.py +49 -0
- pal/metrics/utils/__init__.py +2 -0
- pal/notes_manager/__init__.py +5 -0
- pal/notes_manager/components/__init__.py +0 -0
- pal/notes_manager/components/privacy.py +14 -0
- pal/notes_manager/components/recall.py +31 -0
- pal/notes_manager/components/tidy.py +28 -0
- pal/notes_manager/pipeline.py +138 -0
- pal/orchestrator/__init__.py +26 -0
- pal/orchestrator/components/__init__.py +26 -0
- pal/orchestrator/components/agent.py +105 -0
- pal/orchestrator/components/cgroup_manager.py +209 -0
- pal/orchestrator/components/context_interrupt.py +220 -0
- pal/orchestrator/components/limits.py +9 -0
- pal/orchestrator/components/scheduler.py +69 -0
- pal/orchestrator/components/syscall_scheduler.py +200 -0
- pal/orchestrator/pipeline.py +228 -0
- pal/orchestrator/utils/__init__.py +4 -0
- pal/orchestrator/utils/device.py +164 -0
- pal/orchestrator/utils/protocol.py +32 -0
- pal/os_kernel/__init__.py +5 -0
- pal/os_kernel/filesystem.py +80 -0
- pal/os_kernel/network.py +146 -0
- pal/os_kernel/pipeline.py +175 -0
- pal/os_kernel/sysinfo.py +36 -0
- pal/paths.py +59 -0
- pal/pipeline.py +177 -0
- pal/py.typed +0 -0
- pal/redact.py +19 -0
- pal/sdk/__init__.py +33 -0
- pal/sdk/adapters.py +43 -0
- pal/sdk/agent.py +99 -0
- pal/sdk/components/__init__.py +14 -0
- pal/sdk/components/autogen_adapter.py +65 -0
- pal/sdk/components/autogen_client.py +98 -0
- pal/sdk/components/framework_adapter_base.py +69 -0
- pal/sdk/components/metagpt_adapter.py +46 -0
- pal/sdk/components/open_interpreter_adapter.py +47 -0
- pal/sdk/components/tool_catalog.py +44 -0
- pal/sdk/os_api.py +84 -0
- pal/sdk/patterns.py +129 -0
- pal/sdk/pipeline.py +240 -0
- pal/sdk/queries.py +68 -0
- pal/sdk/savers.py +88 -0
- pal/sdk/utils/__init__.py +2 -0
- pal/storage_manager/__init__.py +17 -0
- pal/storage_manager/components/__init__.py +19 -0
- pal/storage_manager/components/base.py +50 -0
- pal/storage_manager/components/local_file_backend.py +84 -0
- pal/storage_manager/components/storage_queue.py +91 -0
- pal/storage_manager/components/vector_store_backend.py +122 -0
- pal/storage_manager/pipeline.py +153 -0
- pal/storage_manager/utils/__init__.py +4 -0
- pal/storage_manager/utils/memory_adapter.py +60 -0
- pal/storage_manager/utils/sdk.py +93 -0
- pal/studio/__init__.py +0 -0
- pal/studio/builder.py +166 -0
- pal/studio/catalog.py +90 -0
- pal/studio/checks.py +129 -0
- pal/studio/clients.py +6 -0
- pal/studio/editor.py +137 -0
- pal/studio/files.py +6 -0
- pal/studio/models.py +6 -0
- pal/studio/plan.py +149 -0
- pal/studio/planner.py +205 -0
- pal/studio/policy.py +69 -0
- pal/studio/routes.py +254 -0
- pal/studio/runs.py +210 -0
- pal/task_router/__init__.py +11 -0
- pal/task_router/components/__init__.py +0 -0
- pal/task_router/components/answerer.py +37 -0
- pal/task_router/components/base.py +34 -0
- pal/task_router/components/classifier.py +40 -0
- pal/task_router/components/gates.py +23 -0
- pal/task_router/pipeline.py +61 -0
- pal/task_router/utils/__init__.py +1 -0
- pal/terminal/__init__.py +1 -0
- pal/terminal/backend.py +141 -0
- pal/terminal/bench.py +173 -0
- pal/terminal/cli.py +826 -0
- pal/terminal/clients.py +170 -0
- pal/terminal/commands.py +401 -0
- pal/terminal/decide.py +242 -0
- pal/terminal/files.py +55 -0
- pal/terminal/flow_cli.py +439 -0
- pal/terminal/hardware.py +199 -0
- pal/terminal/history.py +88 -0
- pal/terminal/keys.py +83 -0
- pal/terminal/local_models.py +261 -0
- pal/terminal/logo.py +117 -0
- pal/terminal/models.py +99 -0
- pal/terminal/policy.py +54 -0
- pal/terminal/project.py +413 -0
- pal/terminal/review.py +65 -0
- pal/terminal/security.py +239 -0
- pal/terminal/session.py +503 -0
- pal/terminal/setup.py +354 -0
- pal/terminal/syntax.py +98 -0
- pal/terminal/theme.py +37 -0
- pal/terminal/ui.py +647 -0
- pal/terminal/voice.py +137 -0
- pal/terminal/workflow.py +624 -0
- pal/token_tracker/__init__.py +3 -0
- pal/token_tracker/pipeline.py +54 -0
- pal/tokens.py +21 -0
- pal/tool_catalog.py +107 -0
- pal/tool_manager/__init__.py +17 -0
- pal/tool_manager/builtin.py +82 -0
- pal/tool_manager/components/__init__.py +19 -0
- pal/tool_manager/components/base.py +53 -0
- pal/tool_manager/components/conflict_resolver.py +27 -0
- pal/tool_manager/components/param_validator.py +41 -0
- pal/tool_manager/components/tool_call_queue.py +111 -0
- pal/tool_manager/components/tool_registry.py +60 -0
- pal/tool_manager/pipeline.py +80 -0
- pal/tool_manager/utils/__init__.py +3 -0
- pal/web/__init__.py +3 -0
- pal/web/app.py +276 -0
- pal/web/static/editor.js +219 -0
- pal/web/static/fonts/LICENSE-Inter.txt +3 -0
- pal/web/static/fonts/inter-400.woff2 +0 -0
- pal/web/static/fonts/inter-500.woff2 +0 -0
- pal/web/static/fonts/inter-600.woff2 +0 -0
- pal/web/static/fonts/inter-700.woff2 +0 -0
- pal/web/static/inter.css +5 -0
- pal/web/static/pal-logo-256.png +0 -0
- pal/web/static/pal-logo-32.png +0 -0
- pal/web/static/pal-logo-64.png +0 -0
- pal/web/static/studio.css +306 -0
- pal/web/static/studio.js +533 -0
- pal/web/static/voice.js +76 -0
- pal/web/templates/index.html +368 -0
- pal/web/templates/studio.html +120 -0
- pal_code-0.1.0.dist-info/METADATA +379 -0
- pal_code-0.1.0.dist-info/RECORD +282 -0
- pal_code-0.1.0.dist-info/WHEEL +5 -0
- pal_code-0.1.0.dist-info/entry_points.txt +2 -0
- pal_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- pal_code-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fully in-process local core: the model is loaded directly into this
|
|
3
|
+
Python process via transformers, no server (Ollama/llama.cpp/vLLM)
|
|
4
|
+
involved at all. This is the deployment option AIOS 3.2 calls out
|
|
5
|
+
explicitly -- "whether the LLM is hosted on cloud or on local device,
|
|
6
|
+
what hardware conditions the LLM requires" -- that a server-based local
|
|
7
|
+
core doesn't cover, since a server is still a separate process/host
|
|
8
|
+
with its own port.
|
|
9
|
+
|
|
10
|
+
Heavy deps (torch, transformers) are imported lazily inside __init__,
|
|
11
|
+
not at module import time, so importing pal.llm_wrapper doesn't
|
|
12
|
+
require them unless this specific core is actually built.
|
|
13
|
+
|
|
14
|
+
This is also the only core with direct logits access, which is what
|
|
15
|
+
lets it implement the beam-search step/snapshot/restore methods the
|
|
16
|
+
logits-based context interrupt mechanism needs (see
|
|
17
|
+
orchestrator/components/context_interrupt.py) -- no cloud or
|
|
18
|
+
local-server backend exposes raw per-step logits over its API, so
|
|
19
|
+
those cores are permanently limited to text-based snapshotting.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import time
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from pal.llm_wrapper.components.base import (
|
|
28
|
+
DeploymentType,
|
|
29
|
+
LLMResponse,
|
|
30
|
+
LLMSyscall,
|
|
31
|
+
LLMWrapper,
|
|
32
|
+
WrapperCapabilities,
|
|
33
|
+
WrapperConfig,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class BeamSearchState:
|
|
39
|
+
"""The "intermediate search tree structure" a logits-based snapshot
|
|
40
|
+
preserves: every beam's full token sequence so far and its
|
|
41
|
+
cumulative log-probability score. Restoring from this resumes
|
|
42
|
+
decoding exactly where generation left off, one token at a time --
|
|
43
|
+
unlike a text snapshot, nothing has to be re-tokenized or
|
|
44
|
+
re-interpreted by the model to continue.
|
|
45
|
+
|
|
46
|
+
Deliberately does NOT snapshot the model's KV cache (past_key_values)
|
|
47
|
+
-- that would make resumption cheaper (no need to reprocess the
|
|
48
|
+
prefix) but ties the snapshot to a single model instance's live
|
|
49
|
+
memory, and isn't picklable/inspectable the way this plain
|
|
50
|
+
tensor-of-token-ids state is. The tradeoff: resuming here recomputes
|
|
51
|
+
the forward pass over the full sequence-so-far rather than just the
|
|
52
|
+
new token, which is the honest cost of keeping snapshots simple and
|
|
53
|
+
portable.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
input_ids: Any # torch.Tensor, shape (beam_width, seq_len)
|
|
57
|
+
beam_scores: Any # torch.Tensor, shape (beam_width,)
|
|
58
|
+
prompt_len: int
|
|
59
|
+
step: int = 0
|
|
60
|
+
finished: bool = False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class HuggingFaceLocalLLM(LLMWrapper):
|
|
64
|
+
name = "huggingface_local"
|
|
65
|
+
|
|
66
|
+
def __init__(self, config: WrapperConfig):
|
|
67
|
+
super().__init__(config)
|
|
68
|
+
try:
|
|
69
|
+
import torch
|
|
70
|
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
71
|
+
except ImportError as e:
|
|
72
|
+
raise ImportError(
|
|
73
|
+
"transformers and torch are required for the in-process local "
|
|
74
|
+
"core. Run: pip install torch transformers"
|
|
75
|
+
) from e
|
|
76
|
+
|
|
77
|
+
self._torch = torch
|
|
78
|
+
self.model_name = config.model
|
|
79
|
+
# "auto" lets accelerate/torch pick the best available device;
|
|
80
|
+
# explicit device (e.g. "cuda", "cpu", "mps") pins it, matching
|
|
81
|
+
# the "hardware conditions the LLM requires" concern from AIOS.
|
|
82
|
+
device = config.device or "auto"
|
|
83
|
+
|
|
84
|
+
self.tokenizer = AutoTokenizer.from_pretrained(self.model_name)
|
|
85
|
+
self.model = AutoModelForCausalLM.from_pretrained(
|
|
86
|
+
self.model_name,
|
|
87
|
+
device_map=device,
|
|
88
|
+
torch_dtype=config.extra.get("torch_dtype", "auto"),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def _render_prompt(self, messages: list[dict]) -> str:
|
|
92
|
+
if hasattr(self.tokenizer, "apply_chat_template") and self.tokenizer.chat_template:
|
|
93
|
+
return self.tokenizer.apply_chat_template(
|
|
94
|
+
messages, tokenize=False, add_generation_prompt=True
|
|
95
|
+
)
|
|
96
|
+
# No chat template on this tokenizer/model -- fall back to a
|
|
97
|
+
# plain role-prefixed transcript rather than failing outright.
|
|
98
|
+
return "\n".join(f"{m.get('role', 'user')}: {m.get('content', '')}" for m in messages) + "\nassistant:"
|
|
99
|
+
|
|
100
|
+
def chat(self, syscall: LLMSyscall) -> LLMResponse:
|
|
101
|
+
prompt = self._render_prompt(syscall.messages)
|
|
102
|
+
inputs = self.tokenizer(prompt, return_tensors="pt").to(self.model.device)
|
|
103
|
+
|
|
104
|
+
t0 = time.time()
|
|
105
|
+
with self._torch.no_grad():
|
|
106
|
+
output_ids = self.model.generate(
|
|
107
|
+
**inputs,
|
|
108
|
+
max_new_tokens=syscall.max_tokens or 512,
|
|
109
|
+
temperature=syscall.temperature if syscall.temperature is not None else 1.0,
|
|
110
|
+
do_sample=syscall.temperature not in (None, 0),
|
|
111
|
+
**syscall.params,
|
|
112
|
+
)
|
|
113
|
+
latency = time.time() - t0
|
|
114
|
+
|
|
115
|
+
prompt_tokens = inputs["input_ids"].shape[-1]
|
|
116
|
+
completion_tokens = output_ids.shape[-1] - prompt_tokens
|
|
117
|
+
text = self.tokenizer.decode(output_ids[0][prompt_tokens:], skip_special_tokens=True)
|
|
118
|
+
|
|
119
|
+
return LLMResponse(
|
|
120
|
+
text=text,
|
|
121
|
+
raw=output_ids,
|
|
122
|
+
prompt_tokens=prompt_tokens,
|
|
123
|
+
completion_tokens=completion_tokens,
|
|
124
|
+
meta={"latency": latency, "device": str(self.model.device)},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def token_count(self, text: str) -> int:
|
|
128
|
+
return len(self.tokenizer.encode(text))
|
|
129
|
+
|
|
130
|
+
# -- logits-based beam search: step / snapshot / restore -------------------
|
|
131
|
+
#
|
|
132
|
+
# Not part of LLMWrapper's abstract contract (see base.py's LLMWrapper
|
|
133
|
+
# docstring) -- only reachable via capabilities().supports_logits_
|
|
134
|
+
# snapshot, and only ever called by ContextInterruptController.
|
|
135
|
+
|
|
136
|
+
def init_beam_search(self, syscall: LLMSyscall, beam_width: int = 1) -> BeamSearchState:
|
|
137
|
+
prompt = self._render_prompt(syscall.messages)
|
|
138
|
+
inputs = self.tokenizer(prompt, return_tensors="pt").to(self.model.device)
|
|
139
|
+
input_ids = inputs["input_ids"].repeat(beam_width, 1)
|
|
140
|
+
beam_scores = self._torch.zeros(beam_width, device=self.model.device)
|
|
141
|
+
return BeamSearchState(input_ids=input_ids, beam_scores=beam_scores, prompt_len=input_ids.shape[-1])
|
|
142
|
+
|
|
143
|
+
def beam_search_step(self, state: BeamSearchState, max_new_tokens: int = 512) -> BeamSearchState:
|
|
144
|
+
"""Advances every beam by exactly one token. This single-token
|
|
145
|
+
granularity is the actual snapshot boundary: the context
|
|
146
|
+
interrupt mechanism can pause between ANY two calls to this
|
|
147
|
+
method, not just between whole streamed chunks the way the
|
|
148
|
+
text-based path is limited to."""
|
|
149
|
+
if state.finished:
|
|
150
|
+
return state
|
|
151
|
+
|
|
152
|
+
torch = self._torch
|
|
153
|
+
with torch.no_grad():
|
|
154
|
+
outputs = self.model(state.input_ids)
|
|
155
|
+
next_token_logits = outputs.logits[:, -1, :] # (beam_width, vocab)
|
|
156
|
+
next_token_logprobs = torch.log_softmax(next_token_logits, dim=-1)
|
|
157
|
+
beam_width = state.input_ids.shape[0]
|
|
158
|
+
|
|
159
|
+
if beam_width == 1:
|
|
160
|
+
# Beam width 1 (this project's default, matching the
|
|
161
|
+
# AIOS example): greedy is equivalent to beam search
|
|
162
|
+
# with one beam, and avoids the topk/reshape machinery
|
|
163
|
+
# below for the common case.
|
|
164
|
+
next_token_id = next_token_logprobs.argmax(dim=-1, keepdim=True)
|
|
165
|
+
new_scores = state.beam_scores + next_token_logprobs.gather(-1, next_token_id).squeeze(-1)
|
|
166
|
+
new_input_ids = torch.cat([state.input_ids, next_token_id], dim=-1)
|
|
167
|
+
else:
|
|
168
|
+
# True beam search: expand every beam by its full
|
|
169
|
+
# vocabulary of candidates, then keep only the top
|
|
170
|
+
# `beam_width` sequences overall (not top-1-per-beam --
|
|
171
|
+
# that's what lets a single strong beam claim more than
|
|
172
|
+
# one slot next step, same as standard beam search).
|
|
173
|
+
vocab_size = next_token_logprobs.shape[-1]
|
|
174
|
+
candidate_scores = (state.beam_scores.unsqueeze(-1) + next_token_logprobs).view(-1)
|
|
175
|
+
top_scores, top_indices = candidate_scores.topk(beam_width)
|
|
176
|
+
beam_indices = top_indices // vocab_size
|
|
177
|
+
token_indices = (top_indices % vocab_size).unsqueeze(-1)
|
|
178
|
+
new_input_ids = torch.cat([state.input_ids[beam_indices], token_indices], dim=-1)
|
|
179
|
+
new_scores = top_scores
|
|
180
|
+
|
|
181
|
+
generated_len = new_input_ids.shape[-1] - state.prompt_len
|
|
182
|
+
eos_id = self.tokenizer.eos_token_id
|
|
183
|
+
finished = generated_len >= max_new_tokens or (
|
|
184
|
+
eos_id is not None and bool((new_input_ids[:, -1] == eos_id).all())
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
return BeamSearchState(
|
|
188
|
+
input_ids=new_input_ids,
|
|
189
|
+
beam_scores=new_scores,
|
|
190
|
+
prompt_len=state.prompt_len,
|
|
191
|
+
step=state.step + 1,
|
|
192
|
+
finished=finished,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def decode_beam_search(self, state: BeamSearchState) -> str:
|
|
196
|
+
"""Decodes the current best beam's generated tokens. Called both
|
|
197
|
+
mid-generation (to snapshot a human-readable partial answer
|
|
198
|
+
alongside the raw state) and at completion."""
|
|
199
|
+
best_idx = int(state.beam_scores.argmax())
|
|
200
|
+
generated = state.input_ids[best_idx, state.prompt_len:]
|
|
201
|
+
return self.tokenizer.decode(generated, skip_special_tokens=True)
|
|
202
|
+
|
|
203
|
+
def capabilities(self) -> WrapperCapabilities:
|
|
204
|
+
return WrapperCapabilities(
|
|
205
|
+
deployment_type=DeploymentType.LOCAL_IN_PROCESS,
|
|
206
|
+
# The KV cache lives in this same process's memory for the
|
|
207
|
+
# lifetime of self.model -- there's no cross-request reuse
|
|
208
|
+
# concept to expose since nothing is reprocessed for a
|
|
209
|
+
# repeated turn within one generate() call.
|
|
210
|
+
supports_kv_cache=False,
|
|
211
|
+
supports_embeddings=False,
|
|
212
|
+
supports_streaming=False,
|
|
213
|
+
billed_per_token=False,
|
|
214
|
+
# Direct logits access via self.model(...) -- the only core
|
|
215
|
+
# in this project that can offer this.
|
|
216
|
+
supports_logits_snapshot=True,
|
|
217
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""The Local LLM component: models that run on this machine, behind a local server (Ollama, vLLM, LM Studio, llama.cpp).
|
|
2
|
+
|
|
3
|
+
It is the same LiteLLM wrapper underneath, so a local model has the same interface, token and error format as a cloud one, but it differs
|
|
4
|
+
where it matters: there is no key to protect (the OpenAI-style local servers get a placeholder, never a real key from the environment),
|
|
5
|
+
a model's first call may load it (a longer time-out), it costs nothing per token, and it can tell you which models it has installed.
|
|
6
|
+
|
|
7
|
+
The in-process Hugging Face model is the other local kind (huggingface_local.py): LiteLLM calls model servers and cannot load a model into
|
|
8
|
+
this process.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pal.llm_wrapper.components.litellm_wrapper import LOCAL_PREFIX, LiteLLMWrapper
|
|
15
|
+
|
|
16
|
+
DEFAULT_URLS = {"ollama": "http://localhost:11434", "vllm": "http://localhost:8000/v1", "lmstudio": "http://localhost:1234/v1", "llamacpp": "http://localhost:8080/v1"}
|
|
17
|
+
LOCAL_BACKENDS = tuple(LOCAL_PREFIX)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LocalLLM(LiteLLMWrapper):
|
|
21
|
+
name = "local_llm"
|
|
22
|
+
IS_LOCAL = True
|
|
23
|
+
|
|
24
|
+
def __init__(self, config):
|
|
25
|
+
if config.backend not in LOCAL_PREFIX:
|
|
26
|
+
raise ValueError(f"{config.backend!r} is not a local runtime (local runtimes: {', '.join(LOCAL_BACKENDS)})")
|
|
27
|
+
super().__init__(config)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def url(self) -> str:
|
|
31
|
+
return (self.config.base_url or self.config.host or DEFAULT_URLS[self.config.backend]).rstrip("/")
|
|
32
|
+
|
|
33
|
+
def installed_models(self, timeout: float = 3.0, get: Any = None) -> list[str]:
|
|
34
|
+
"""The models this server has, as it names them: Ollama's /api/tags, or the OpenAI-style /models of the others. An empty list when the
|
|
35
|
+
server cannot be reached (see `is_running`). `get` is for tests; it defaults to `requests.get`."""
|
|
36
|
+
if get is None:
|
|
37
|
+
import requests
|
|
38
|
+
get = requests.get
|
|
39
|
+
try:
|
|
40
|
+
if self.config.backend == "ollama":
|
|
41
|
+
r = get(f"{self.url}/api/tags", timeout=timeout)
|
|
42
|
+
r.raise_for_status()
|
|
43
|
+
return sorted(str(m.get("name") or m.get("model")) for m in r.json().get("models", []) if m.get("name") or m.get("model"))
|
|
44
|
+
r = get(f"{self.url}/models", timeout=timeout)
|
|
45
|
+
r.raise_for_status()
|
|
46
|
+
return sorted(str(m["id"]) for m in r.json().get("data", []) if m.get("id"))
|
|
47
|
+
except Exception: # noqa: BLE001 (not running, not reachable, or not the API we expected)
|
|
48
|
+
return []
|
|
49
|
+
|
|
50
|
+
def is_running(self, timeout: float = 3.0, get: Any = None) -> bool:
|
|
51
|
+
"""Is the server up? (It answers a models request, even with no models yet.)"""
|
|
52
|
+
if get is None:
|
|
53
|
+
import requests
|
|
54
|
+
get = requests.get
|
|
55
|
+
try:
|
|
56
|
+
path = "/api/tags" if self.config.backend == "ollama" else "/models"
|
|
57
|
+
get(f"{self.url}{path}", timeout=timeout).raise_for_status()
|
|
58
|
+
return True
|
|
59
|
+
except Exception: # noqa: BLE001
|
|
60
|
+
return False
|
pal/logging_setup.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Logging setup for entry points. Library code only does `logging.getLogger(__name__)`; only the
|
|
2
|
+
CLI/server calls configure_logging(). Secrets are never logged: API keys never enter a log call."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def configure_logging(level: str = "INFO") -> None:
|
|
10
|
+
root = logging.getLogger("pal")
|
|
11
|
+
if root.handlers:
|
|
12
|
+
root.setLevel(level.upper())
|
|
13
|
+
return
|
|
14
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
15
|
+
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
|
|
16
|
+
root.addHandler(handler)
|
|
17
|
+
root.setLevel(level.upper())
|
|
18
|
+
root.propagate = False
|
pal/memory/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from pal.memory.components.lru_k import LRUKTracker
|
|
2
|
+
from pal.memory.components.manager import MemoryManager
|
|
3
|
+
from pal.memory.components.note import MemoryNote
|
|
4
|
+
from pal.memory.components.storage_manager import StorageManager
|
|
5
|
+
from pal.memory.pipeline import MemoryPipeline
|
|
6
|
+
|
|
7
|
+
__all__ = ["MemoryPipeline", "MemoryManager", "MemoryNote", "StorageManager", "LRUKTracker"]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
from pal.memory.components.lru_k import LRUKTracker
|
|
2
|
+
from pal.memory.components.manager import MemoryManager
|
|
3
|
+
from pal.memory.components.note import MemoryNote
|
|
4
|
+
from pal.memory.components.storage_manager import StorageManager
|
|
5
|
+
|
|
6
|
+
__all__ = ["LRUKTracker", "MemoryManager", "MemoryNote", "StorageManager"]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LRU-K tracking (O'Neil, O'Neil & Weikum, 1993) -- the eviction policy
|
|
3
|
+
AIOS section 3.5 specifies for its memory manager, used here to decide
|
|
4
|
+
which RAM-cached MemoryNotes get swapped to disk once the cache nears
|
|
5
|
+
capacity.
|
|
6
|
+
|
|
7
|
+
Plain LRU only looks at the single most recent access, so a note that
|
|
8
|
+
was accessed once, long ago, and a note accessed constantly until just
|
|
9
|
+
a moment ago can look identically "recently used" the instant both get
|
|
10
|
+
touched again -- LRU can't tell a one-off touch from a genuinely hot
|
|
11
|
+
item. LRU-K's backward K-distance (how long ago an item's K-th most
|
|
12
|
+
recent access happened, not just its most recent one) is what "at
|
|
13
|
+
least K times recently" in the spec actually cashes out to: an item
|
|
14
|
+
needs a real access HISTORY, not just a single recent touch, to count
|
|
15
|
+
as hot.
|
|
16
|
+
"""
|
|
17
|
+
import math
|
|
18
|
+
import time
|
|
19
|
+
from collections import deque
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LRUKTracker:
|
|
23
|
+
def __init__(self, k: int = 2):
|
|
24
|
+
self.k = k
|
|
25
|
+
self._history: dict[str, deque] = {}
|
|
26
|
+
|
|
27
|
+
def record_access(self, item_id: str, when: float = None) -> None:
|
|
28
|
+
when = when if when is not None else time.time()
|
|
29
|
+
hist = self._history.setdefault(item_id, deque(maxlen=self.k))
|
|
30
|
+
hist.append(when)
|
|
31
|
+
|
|
32
|
+
def backward_k_distance(self, item_id: str, now: float = None) -> float:
|
|
33
|
+
"""How long ago this item's K-th most recent access happened.
|
|
34
|
+
Items with fewer than K recorded accesses ever get infinite
|
|
35
|
+
distance -- they haven't earned "accessed at least K times
|
|
36
|
+
recently" status yet, so they're always evicted ahead of
|
|
37
|
+
anything that has."""
|
|
38
|
+
now = now if now is not None else time.time()
|
|
39
|
+
hist = self._history.get(item_id)
|
|
40
|
+
if not hist or len(hist) < self.k:
|
|
41
|
+
return math.inf
|
|
42
|
+
return now - hist[0] # oldest of the last K accesses
|
|
43
|
+
|
|
44
|
+
def forget(self, item_id: str) -> None:
|
|
45
|
+
self._history.pop(item_id, None)
|
|
46
|
+
|
|
47
|
+
def eviction_order(self, candidate_ids: list[str], now: float = None) -> list[str]:
|
|
48
|
+
"""Returns candidate_ids ordered worst-to-best for retention --
|
|
49
|
+
the FIRST id in the result is the one to evict first (largest
|
|
50
|
+
backward K-distance = coldest). Python's sort is stable, so
|
|
51
|
+
ties (most commonly: several items all still below K accesses,
|
|
52
|
+
all at infinite distance) keep their relative order from
|
|
53
|
+
candidate_ids -- in practice, whichever of them was inserted
|
|
54
|
+
into the RAM cache earliest."""
|
|
55
|
+
now = now if now is not None else time.time()
|
|
56
|
+
return sorted(candidate_ids, key=lambda i: self.backward_k_distance(i, now), reverse=True)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Runtime memory manager for agent interaction history (AIOS section
|
|
3
|
+
3.5). Unlike a traditional OS memory manager, which manages physical
|
|
4
|
+
RAM pages, this one manages MemoryNote objects -- conversation turns,
|
|
5
|
+
tool-call results, and similar agent history -- between an in-process
|
|
6
|
+
RAM cache and on-disk persistence via StorageManager.
|
|
7
|
+
|
|
8
|
+
Every write is persisted to disk immediately: this project has no
|
|
9
|
+
concept of "unsaved" agent memory, durability isn't optional here, only
|
|
10
|
+
RAM residency is. The RAM cache exists purely as a bounded-size fast
|
|
11
|
+
path so repeated access to hot notes doesn't round-trip through disk
|
|
12
|
+
every time. When the cache's size crosses `high_water_mark` (default
|
|
13
|
+
80%) of `block_limit`, the manager evicts the coldest RAM-resident
|
|
14
|
+
notes by LRU-K backward-distance (components/lru_k.py) until it's back
|
|
15
|
+
under the mark. "Evicted" only means dropped from the RAM cache --
|
|
16
|
+
nothing is lost, since it was already durably written to disk on
|
|
17
|
+
admission; the next read() for that id reloads it from disk and
|
|
18
|
+
re-admits it to RAM (a swap-in).
|
|
19
|
+
"""
|
|
20
|
+
import time
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
from pal.memory.components.lru_k import LRUKTracker
|
|
24
|
+
from pal.memory.components.note import MemoryNote
|
|
25
|
+
from pal.memory.components.storage_manager import StorageManager
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MemoryManager:
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
storage: StorageManager,
|
|
32
|
+
block_limit: int = 200,
|
|
33
|
+
high_water_mark: float = 0.8,
|
|
34
|
+
k: int = 2,
|
|
35
|
+
):
|
|
36
|
+
self.storage = storage
|
|
37
|
+
self.block_limit = block_limit
|
|
38
|
+
self.high_water_mark = high_water_mark
|
|
39
|
+
self.ram: dict[str, MemoryNote] = {}
|
|
40
|
+
self.lru_k = LRUKTracker(k=k)
|
|
41
|
+
|
|
42
|
+
# Warm the RAM cache from whatever's already durably persisted,
|
|
43
|
+
# most-recently-touched first, up to the block limit -- so a
|
|
44
|
+
# fresh process (a new session against an existing memory
|
|
45
|
+
# store) doesn't start with a cold cache and immediately thrash
|
|
46
|
+
# on its first few reads.
|
|
47
|
+
for note in sorted(self.storage.read_all(), key=lambda n: n.last_accessed, reverse=True)[: self.block_limit]:
|
|
48
|
+
self.ram[note.id] = note
|
|
49
|
+
self.lru_k.record_access(note.id, note.last_accessed)
|
|
50
|
+
|
|
51
|
+
def usage_ratio(self) -> float:
|
|
52
|
+
return len(self.ram) / self.block_limit if self.block_limit else 0.0
|
|
53
|
+
|
|
54
|
+
def write(self, note: MemoryNote) -> None:
|
|
55
|
+
"""Allocates (or updates) a note: persisted to disk immediately,
|
|
56
|
+
cached in RAM, and recorded as an access. May trigger eviction
|
|
57
|
+
if this pushes the RAM cache over its high-water mark."""
|
|
58
|
+
self.storage.write(note)
|
|
59
|
+
self.ram[note.id] = note
|
|
60
|
+
self.lru_k.record_access(note.id)
|
|
61
|
+
self._maybe_evict()
|
|
62
|
+
|
|
63
|
+
def read(self, note_id: str) -> Optional[MemoryNote]:
|
|
64
|
+
"""Reads a note by id -- from RAM if resident, else swaps it in
|
|
65
|
+
from disk. Returns None if the id doesn't exist anywhere."""
|
|
66
|
+
note = self.ram.get(note_id)
|
|
67
|
+
if note is None:
|
|
68
|
+
note = self.storage.read(note_id)
|
|
69
|
+
if note is None:
|
|
70
|
+
return None
|
|
71
|
+
self.ram[note_id] = note # swap-in
|
|
72
|
+
|
|
73
|
+
note.retrieval_count += 1
|
|
74
|
+
note.last_accessed = time.time()
|
|
75
|
+
self.lru_k.record_access(note_id, note.last_accessed)
|
|
76
|
+
self.storage.write(note) # persist the updated access metadata
|
|
77
|
+
self._maybe_evict()
|
|
78
|
+
return note
|
|
79
|
+
|
|
80
|
+
def delete(self, note_id: str) -> None:
|
|
81
|
+
self.ram.pop(note_id, None)
|
|
82
|
+
self.storage.delete(note_id)
|
|
83
|
+
self.lru_k.forget(note_id)
|
|
84
|
+
|
|
85
|
+
def all_ids(self) -> list[str]:
|
|
86
|
+
"""Every note id known to the system, RAM-resident or swapped
|
|
87
|
+
to disk."""
|
|
88
|
+
return self.storage.list_ids()
|
|
89
|
+
|
|
90
|
+
def _maybe_evict(self) -> None:
|
|
91
|
+
while self.usage_ratio() >= self.high_water_mark and self.ram:
|
|
92
|
+
victim_id = self.lru_k.eviction_order(list(self.ram.keys()))[0]
|
|
93
|
+
# Already durable on disk from write() -- eviction here
|
|
94
|
+
# only drops the RAM cache entry, nothing more needs to be
|
|
95
|
+
# written. LRU-K access history is deliberately kept (not
|
|
96
|
+
# forgotten) for the evicted item, so if it's swapped back
|
|
97
|
+
# in later its recency history isn't reset to zero.
|
|
98
|
+
del self.ram[victim_id]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A single persistent memory unit. Design adapted from AIOS
|
|
3
|
+
(github.com/agiresearch/AIOS aios/memory/note.py) but trimmed to what
|
|
4
|
+
Pal actually needs -- no evolution_history/links graph, since that
|
|
5
|
+
requires the heavier retrieval stack (chromadb/qdrant) AIOS uses, which
|
|
6
|
+
this lightweight module deliberately avoids.
|
|
7
|
+
"""
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MemoryNote:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
content: str,
|
|
17
|
+
id: Optional[str] = None,
|
|
18
|
+
session_id: Optional[str] = None,
|
|
19
|
+
timestamp: Optional[float] = None,
|
|
20
|
+
last_accessed: Optional[float] = None,
|
|
21
|
+
retrieval_count: int = 0,
|
|
22
|
+
tags: Optional[list[str]] = None,
|
|
23
|
+
):
|
|
24
|
+
self.id = id or str(uuid.uuid4())
|
|
25
|
+
self.content = content
|
|
26
|
+
self.session_id = session_id
|
|
27
|
+
now = time.time()
|
|
28
|
+
self.timestamp = timestamp if timestamp is not None else now
|
|
29
|
+
self.last_accessed = last_accessed if last_accessed is not None else now
|
|
30
|
+
self.retrieval_count = retrieval_count
|
|
31
|
+
self.tags = tags or []
|
|
32
|
+
|
|
33
|
+
def to_dict(self) -> dict:
|
|
34
|
+
return {
|
|
35
|
+
"id": self.id,
|
|
36
|
+
"content": self.content,
|
|
37
|
+
"session_id": self.session_id,
|
|
38
|
+
"timestamp": self.timestamp,
|
|
39
|
+
"last_accessed": self.last_accessed,
|
|
40
|
+
"retrieval_count": self.retrieval_count,
|
|
41
|
+
"tags": self.tags,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_dict(cls, d: dict) -> "MemoryNote":
|
|
46
|
+
return cls(
|
|
47
|
+
content=d["content"],
|
|
48
|
+
id=d.get("id"),
|
|
49
|
+
session_id=d.get("session_id"),
|
|
50
|
+
timestamp=d.get("timestamp"),
|
|
51
|
+
last_accessed=d.get("last_accessed"),
|
|
52
|
+
retrieval_count=d.get("retrieval_count", 0),
|
|
53
|
+
tags=d.get("tags", []),
|
|
54
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Minimal disk-backed persistence for MemoryNotes: one JSON file per note
|
|
3
|
+
under `root`, keyed by note id.
|
|
4
|
+
|
|
5
|
+
Stands in for the full Storage Manager AIOS section 3.6 describes (a
|
|
6
|
+
general read/write file-access layer for agents) -- this project
|
|
7
|
+
doesn't implement that broader module yet, only the narrow slice the
|
|
8
|
+
memory manager's RAM<->disk swapping needs: durably persist a note,
|
|
9
|
+
load it back by id, delete it, and enumerate every persisted id.
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from pal.memory.components.note import MemoryNote
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StorageManager:
|
|
19
|
+
def __init__(self, root: Path):
|
|
20
|
+
self.root = Path(root)
|
|
21
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
|
|
23
|
+
def _path(self, note_id: str) -> Path:
|
|
24
|
+
return self.root / f"{note_id}.json"
|
|
25
|
+
|
|
26
|
+
def write(self, note: MemoryNote) -> None:
|
|
27
|
+
with open(self._path(note.id), "w", encoding="utf-8") as f:
|
|
28
|
+
json.dump(note.to_dict(), f)
|
|
29
|
+
|
|
30
|
+
def read(self, note_id: str) -> Optional[MemoryNote]:
|
|
31
|
+
path = self._path(note_id)
|
|
32
|
+
if not path.exists():
|
|
33
|
+
return None
|
|
34
|
+
try:
|
|
35
|
+
with open(path, encoding="utf-8") as f:
|
|
36
|
+
return MemoryNote.from_dict(json.load(f))
|
|
37
|
+
except (json.JSONDecodeError, KeyError):
|
|
38
|
+
# Corrupt/partial file (e.g. killed mid-write) -- treat as
|
|
39
|
+
# missing rather than raising, same tolerance the old
|
|
40
|
+
# single-file store had for a corrupt memory.json.
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
def delete(self, note_id: str) -> None:
|
|
44
|
+
path = self._path(note_id)
|
|
45
|
+
if path.exists():
|
|
46
|
+
path.unlink()
|
|
47
|
+
|
|
48
|
+
def exists(self, note_id: str) -> bool:
|
|
49
|
+
return self._path(note_id).exists()
|
|
50
|
+
|
|
51
|
+
def list_ids(self) -> list[str]:
|
|
52
|
+
return [p.stem for p in self.root.glob("*.json")]
|
|
53
|
+
|
|
54
|
+
def read_all(self) -> list[MemoryNote]:
|
|
55
|
+
notes = []
|
|
56
|
+
for note_id in self.list_ids():
|
|
57
|
+
note = self.read(note_id)
|
|
58
|
+
if note is not None:
|
|
59
|
+
notes.append(note)
|
|
60
|
+
return notes
|