projects preps
frdel committed
Oct 23, 2025 at 13:08 UTC
c4bf352d9c52bae9da4cc9dd9fda06fd3059b51c
9 files changed
+236
-4
agent.py
+11
-2
@@ -53,6 +53,7 @@ class AgentContext:
53
created_at: datetime | None = None,
54
type: AgentContextType = AgentContextType.USER,
55
last_message: datetime | None = None,
56
+ data: dict | None = None,
57
):
58
# build context
59
self.id = id or AgentContext.generate_id()
@@ -67,8 +68,8 @@ class AgentContext:
68
self.type = type
69
AgentContext._counter += 1
70
self.no = AgentContext._counter
70
- # set to start of unix epoch
71
self.last_message = last_message or datetime.now(timezone.utc)
72
+ self.data = data or {}
73
74
existing = self._contexts.get(self.id, None)
75
if existing:
@@ -112,7 +113,15 @@ class AgentContext:
113
context.task.kill()
114
return context
115
115
- def serialize(self):
116
+ def get_data(self, key: str, recursive: bool = True):
117
+ # recursive is not used now, prepared for context hierarchy
118
+ return self.data.get(key, None)
119
+
120
+ def set_data(self, key: str, value: Any, recursive: bool = True):
121
+ # recursive is not used now, prepared for context hierarchy
122
+ self.data[key] = value
123
+
124
+ def output(self):
125
return {
126
"id": self.id,
127
"name": self.name,
python/api/poll.py
+1
-1
@@ -54,7 +54,7 @@ class Poll(ApiHandler):
54
continue
55
56
# Create the base context data that will be returned
57
- context_data = ctx.serialize()
57
+ context_data = ctx.output()
58
59
context_task = scheduler.get_task_by_uuid(ctx.id)
60
# Determine if this is a task-dedicated context by checking if a task with this UUID exists
python/api/projects.py
new
+26
@@ -0,0 +1,26 @@
1
+from python.helpers.api import ApiHandler, Input, Output, Request, Response
2
+from python.helpers import projects
3
+
4
+
5
+class Projects(ApiHandler):
6
+ async def process(self, input: Input, request: Request) -> Output:
7
+ action = input.get("action", "")
8
+
9
+ try:
10
+ if action == "list":
11
+ data = self.get_projects_list()
12
+ else:
13
+ raise Exception("Invalid action")
14
+
15
+ return {
16
+ "ok": True,
17
+ "data": data,
18
+ }
19
+ except Exception as e:
20
+ return {
21
+ "ok": False,
22
+ "error": str(e),
23
+ }
24
+
25
+ async def get_projects_list(self):
26
+ return await projects.get_projects_list()
python/helpers/files.py
+34
@@ -334,6 +334,40 @@ def delete_dir(relative_path: str):
334
# suppress all errors - we're ensuring no errors propagate
335
pass
336
337
+def move_dir(old_path: str, new_path: str):
338
+ # rename/move the directory from old_path to new_path (both relative)
339
+ abs_old = get_abs_path(old_path)
340
+ abs_new = get_abs_path(new_path)
341
+ if not os.path.isdir(abs_old):
342
+ return # nothing to rename
343
+ try:
344
+ os.rename(abs_old, abs_new)
345
+ except Exception:
346
+ pass # suppress all errors, keep behavior consistent
347
+
348
+# move dir safely, remove with number if needed
349
+def move_dir_safe(src, dst):
350
+ base_dst = dst
351
+ i = 2
352
+ while exists(dst):
353
+ dst = f"{base_dst} {i}"
354
+ i += 1
355
+ move_dir(src, dst)
356
+ return dst
357
+
358
+# create dir safely, add number if needed
359
+def create_dir_safe(dst):
360
+ base_dst = dst
361
+ i = 2
362
+ while exists(dst):
363
+ dst = f"{base_dst} {i}"
364
+ i += 1
365
+ create_dir(dst)
366
+ return dst
367
+
368
+def create_dir(relative_path: str):
369
+ abs_path = get_abs_path(relative_path)
370
+ os.makedirs(abs_path, exist_ok=True)
371
372
def list_files(relative_path: str, filter: str = "*"):
373
abs_path = get_abs_path(relative_path)
python/helpers/projects.py
new
+111
@@ -0,0 +1,111 @@
1
+from dataclasses import dataclass
2
+import os
3
+from typing import TypedDict
4
+
5
+from torch import ne
6
+from python.helpers import files, dirty_json
7
+from python.helpers.print_style import PrintStyle
8
+
9
+PROJECTS_PARENT_DIR = "tmp/projects"
10
+PROJECTS_ARCHIVE_DIR = "tmp/projects-archived"
11
+PROJECT_META_DIR = ".a0proj"
12
+PROJECT_INSTRUCTIONS_DIR = "instructions"
13
+PROJECT_HEADER_FILE = "project.json"
14
+
15
+
16
+class BasicProjectData(TypedDict):
17
+ title: str | None
18
+ description: str | None
19
+ instructions: str | None
20
+ color: str | None
21
+
22
+
23
+def get_projects_parent_folder():
24
+ return files.get_abs_path(PROJECTS_PARENT_DIR)
25
+
26
+
27
+def get_projects_archive_folder():
28
+ return files.get_abs_path(PROJECTS_ARCHIVE_DIR)
29
+
30
+
31
+def get_project_folder(name: str):
32
+ return files.get_abs_path(get_projects_parent_folder(), name)
33
+
34
+
35
+def get_archived_project_folder(name: str):
36
+ return files.get_abs_path(get_projects_archive_folder(), name)
37
+
38
+
39
+def archive_project(name: str):
40
+ return files.move_dir_safe(
41
+ get_project_folder(name), get_archived_project_folder(name)
42
+ )
43
+
44
+
45
+def unarchive_project(name: str):
46
+ return files.move_dir_safe(
47
+ get_archived_project_folder(name), get_project_folder(name)
48
+ )
49
+
50
+
51
+def delete_project(path: str):
52
+ files.delete_dir(path)
53
+
54
+
55
+async def create_project(name: str, data: BasicProjectData):
56
+ new_name = files.create_dir_safe(get_project_folder(name))
57
+ save_project_files(new_name, data)
58
+
59
+
60
+async def update_project(path: str, data: BasicProjectData):
61
+ current: BasicProjectData = load_basic_project_data(path) # type: ignore
62
+ current.update(data)
63
+ save_project_files(path, current)
64
+
65
+
66
+def load_basic_project_data(path: str) -> BasicProjectData:
67
+ data = dirty_json.parse(
68
+ files.read_file(
69
+ files.get_abs_path(path, PROJECT_HEADER_FILE)
70
+ )
71
+ )
72
+ return data # type: ignore
73
+
74
+
75
+def save_project_files(path: str, data: BasicProjectData):
76
+ # save project header file
77
+ header = dirty_json.stringify(data)
78
+ files.write_file(
79
+ files.get_abs_path(path, PROJECT_HEADER_FILE), header
80
+ )
81
+
82
+async def get_active_projects_list():
83
+ return await get_projects_list(get_projects_parent_folder())
84
+
85
+async def get_archived_projects_list():
86
+ return await get_projects_list(get_projects_archive_folder())
87
+
88
+async def get_projects_list(parent_dir):
89
+ projects = []
90
+
91
+ # folders in project directory
92
+ for name in os.listdir(parent_dir):
93
+ try:
94
+ path = os.path.join(parent_dir, name)
95
+ if os.path.isdir(path):
96
+
97
+ project_data = load_basic_project_data(path)
98
+
99
+ projects.append({
100
+ "name": name,
101
+ "path": path,
102
+ "title": project_data.get("title", ""),
103
+ "description": project_data.get("description", ""),
104
+ })
105
+ except Exception as e:
106
+ PrintStyle.error(f"Error loading project {name}: {str(e)}")
107
+
108
+ # sort projects by name
109
+
110
+ projects.sort(key=lambda x: x["name"])
111
+ return projects
python/helpers/tokens.py
+1
-1
@@ -13,7 +13,7 @@ def count_tokens(text: str, encoding_name="cl100k_base") -> int:
13
encoding = tiktoken.get_encoding(encoding_name)
14
15
# Encode the text and count the tokens
16
- tokens = encoding.encode(text)
16
+ tokens = encoding.encode(text, disallowed_special=())
17
token_count = len(tokens)
18
19
return token_count
webui/components/projects/project-selector.html
new
+24
@@ -0,0 +1,24 @@
1
+<html>
2
+
3
+<head>
4
+ <script type="module">
5
+ import { store } from "/components/projects/projectsStore.js";
6
+ </script>
7
+</head>
8
+
9
+<body>
10
+ <div x-data>
11
+ <template x-if="$store.projects">
12
+ <div>
13
+ <template x-if="$store.selectedProject">
14
+ <button type="button" class="btn btn-outline-primary" x-text="$store.selectedProject.name"></button>
15
+ </template>
16
+ <template x-if="!$store.selectedProject">
17
+ <button type="button" class="btn btn-outline-secondary">No project</button>
18
+ </template>
19
+ </div>
20
+ </template>
21
+ </div>
22
+</body>
23
+
24
+</html>
\ No newline at end of file
webui/components/projects/projectsStore.js
new
+26
@@ -0,0 +1,26 @@
1
+import { createStore } from "/js/AlpineStore.js";
2
+import * as api from "/js/api.js";
3
+
4
+// define the model object holding data and functions
5
+const model = {
6
+ projectList: [],
7
+ selectedProject: null,
8
+
9
+ async loadProjectsList() {
10
+ this.loading = true;
11
+ try {
12
+ const response = await api.callJsonApi("projects", { action: "list" });
13
+ this.projectList = response.data;
14
+ } catch (error) {
15
+ console.error("Error loading projects list:", error);
16
+ } finally {
17
+ this.loading = false;
18
+ }
19
+ },
20
+};
21
+
22
+// convert it to alpine store
23
+const store = createStore("projects", model);
24
+
25
+// export for use in other files
26
+export { store };
webui/index.html
+2
@@ -161,6 +161,8 @@
161
</div>
162
<!-- Notification Toggle positioned next to time-date -->
163
<x-component path="notifications/notification-icons.html"></x-component>
164
+ <!-- Project Selector -->
165
+ <x-component path="projects/project-selector.html"></x-component>
166
</div>
167
<!-- Message History (actual messages) -->
168
<div id="chat-history">