ai_co_discuss 0.3.46 → 0.3.49

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.
Files changed (43) hide show
  1. package/.agents/skills/discuss-host/SKILL.md +1 -0
  2. package/.agents/skills/discuss-host/references/discussion.md +113 -113
  3. package/.agents/skills/discuss-host/references/free.md +36 -34
  4. package/.agents/skills/discuss-host/references/goal.md +35 -0
  5. package/.agents/skills/discuss-participant/SKILL.md +1 -0
  6. package/.agents/skills/workflow-creator/SKILL.md +2 -2
  7. package/README.md +3 -1
  8. package/backend/admin.py +104 -35
  9. package/backend/free_room.py +2170 -2136
  10. package/backend/goal_room.py +358 -0
  11. package/backend/mode_room.py +105 -0
  12. package/backend/schema.sql +1593 -1510
  13. package/backend/server.py +2636 -2555
  14. package/backend/session_room.py +1180 -1148
  15. package/docs/THIRD_PARTY_NOTICES.md +1293 -1264
  16. package/docs/deployment.md +5 -1
  17. package/docs/en/workflow.md +15 -1
  18. package/docs/runner.md +28 -7
  19. package/docs/workflow.md +15 -1
  20. package/package.json +3 -2
  21. package/runner/adapters/api.mjs +333 -313
  22. package/runner/adapters/claude.mjs +12 -16
  23. package/runner/admin_account.mjs +12 -19
  24. package/runner/device_auth.mjs +369 -0
  25. package/runner/discuss.py +1208 -1125
  26. package/runner/discussion_launch.mjs +107 -12
  27. package/runner/discussion_service.mjs +25 -1
  28. package/runner/expert_prompts.mjs +132 -132
  29. package/runner/free_runner.mjs +982 -910
  30. package/runner/pinyin_table.mjs +406 -0
  31. package/runner/project_files.mjs +52 -3
  32. package/runner/relay_channel.mjs +51 -7
  33. package/runner/remote_launch.mjs +221 -235
  34. package/runner/remote_policy.mjs +156 -0
  35. package/runner/scheduler.mjs +459 -429
  36. package/runner/stt.mjs +2 -2
  37. package/runner/tts.mjs +74 -113
  38. package/runner/tts_providers.mjs +205 -0
  39. package/runner/webauthn.mjs +155 -0
  40. package/runner/workflows.mjs +371 -348
  41. package/web/app.js +355 -51
  42. package/web/modern.css +1 -1
  43. package/web/modern.js +5764 -3816
package/backend/admin.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """Single site administrator account: registration, login sessions and profile.
2
2
 
3
3
  Passwords and session tokens are stored only as hashes. The Worker backend implements
4
- the same contract in ``worker.mjs``; both operate on ``site_admin`` and ``admin_sessions``.
4
+ the same contract in ``worker.mjs``; both operate on ``site_admin``, ``admin_logins`` and ``admin_attempts``.
5
5
  """
6
6
 
7
7
  import hashlib
@@ -12,8 +12,18 @@ import time
12
12
  from .runtime import begin_write
13
13
 
14
14
  PBKDF2_ITERATIONS = 100000
15
- LOCK_AFTER_FAILURES = 5
15
+ # A login ends after a week unused or a month in all; same rules as worker.mjs.
16
+ SESSION_IDLE_SECONDS = 7 * 86400
17
+ SESSION_MAX_SECONDS = 30 * 86400
18
+ SESSION_SEEN_SECONDS = 3600
19
+ SESSION_LABEL_LIMIT = 160
20
+ # Each run of five wrong passwords from one address locks it, twice as long each time, up to a day; all addresses
21
+ # together are capped per hour.
22
+ FAILURES_PER_LOCK = 5
16
23
  LOCK_SECONDS = 300
24
+ LOCK_MAX_SECONDS = 86400
25
+ GLOBAL_FAILURES = 500
26
+ GLOBAL_WINDOW = 3600
17
27
  USERNAME_MAX = 30
18
28
  PASSWORD_MIN = 8
19
29
  PASSWORD_MAX = 128
@@ -87,10 +97,19 @@ def profile_of(row):
87
97
  return {"username": row["username"], "avatar": row["avatar"]} if row else None
88
98
 
89
99
 
90
- def session_valid(connection, token):
91
- """Whether the token belongs to a stored session; sessions last until logout."""
100
+ def live_session(connection, token, now=None):
101
+ """Return the login row for a token that is neither idle too long nor too old, or None."""
92
102
  hashed = session_hash(token)
93
- return bool(hashed and connection.execute("SELECT 1 FROM admin_sessions WHERE token_hash=?", (hashed,)).fetchone())
103
+ if not hashed:
104
+ return None
105
+ now = int(time.time()) if now is None else now
106
+ return connection.execute("SELECT * FROM admin_logins WHERE token_hash=? AND last_seen>=? AND created_at>=?",
107
+ (hashed, now - SESSION_IDLE_SECONDS, now - SESSION_MAX_SECONDS)).fetchone()
108
+
109
+
110
+ def session_valid(connection, token):
111
+ """Whether the token belongs to a live login; reading never writes, the status check marks last use."""
112
+ return live_session(connection, token) is not None
94
113
 
95
114
 
96
115
  def require_session(connection, token):
@@ -107,18 +126,25 @@ def admin_status(connection, token, setup_available, local=False):
107
126
  row = admin_row(connection)
108
127
  if local:
109
128
  return {"local": True, "registered": True, "setup_available": False, "logged_in": True, "profile": profile_of(row)}
129
+ now = int(time.time())
130
+ session = live_session(connection, token, now) if row else None
131
+ # Pages ask for this status when they open, which is when a login counts as used.
132
+ if session and now - session["last_seen"] > SESSION_SEEN_SECONDS:
133
+ connection.execute("UPDATE admin_logins SET last_seen=? WHERE token_hash=?", (now, session["token_hash"]))
110
134
  return {"registered": bool(row), "setup_available": bool(setup_available and not row),
111
- "logged_in": session_valid(connection, token) if row else False, "profile": profile_of(row)}
135
+ "logged_in": session is not None, "profile": profile_of(row)}
112
136
 
113
137
 
114
- def open_session(connection, now):
115
- """Create a session and return its raw token, which is shown only once."""
138
+ def open_session(connection, now, label=""):
139
+ """Create a login labelled with the browser that asked for it and return its raw token, shown only once."""
116
140
  token = secrets.token_hex(32)
117
- connection.execute("INSERT INTO admin_sessions (token_hash,created_at) VALUES (?,?)", (session_hash(token), now))
141
+ connection.execute("DELETE FROM admin_logins WHERE last_seen<? OR created_at<?", (now - SESSION_IDLE_SECONDS, now - SESSION_MAX_SECONDS))
142
+ connection.execute("INSERT INTO admin_logins (token_hash,label,created_at,last_seen) VALUES (?,?,?,?)",
143
+ (session_hash(token), str(label or "")[:SESSION_LABEL_LIMIT], now, now))
118
144
  return token
119
145
 
120
146
 
121
- def register_admin(connection, payload, setup_key, now):
147
+ def register_admin(connection, payload, setup_key, now, label=""):
122
148
  """Create the administrator when the install token matches; only the first call succeeds."""
123
149
  begin_write(connection)
124
150
  if admin_row(connection):
@@ -135,42 +161,85 @@ def register_admin(connection, payload, setup_key, now):
135
161
  connection.execute("INSERT INTO site_admin (id,username,password_hash,created_at) VALUES (1,?,?,?)",
136
162
  (username, hash_password(password), now))
137
163
  connection.execute("DELETE FROM admin_setup")
138
- return {"token": open_session(connection, now), "profile": {"username": username, "avatar": ""}}
139
-
140
-
141
- def record_failure(connection, row):
142
- """Count a failed attempt durably; the fifth in a row locks login for a fixed time."""
143
- failures = row["failed_attempts"] + 1
144
- locked = failures >= LOCK_AFTER_FAILURES
145
- connection.execute("UPDATE site_admin SET failed_attempts=?, locked_until=? WHERE id=1",
146
- (0 if locked else failures, int(time.time()) + LOCK_SECONDS if locked else 0))
164
+ return {"token": open_session(connection, now, label), "profile": {"username": username, "avatar": ""}}
165
+
166
+
167
+ def check_password(connection, row, password, source, now):
168
+ """Verify a password with backoff per address and a cap across all addresses; failures are committed at once."""
169
+ attempts = connection.execute("SELECT * FROM admin_attempts WHERE source=?", (source,)).fetchone()
170
+ every = connection.execute("SELECT * FROM admin_attempts WHERE source='*'").fetchone()
171
+ for limit in (attempts, every):
172
+ if limit and limit["locked_until"] > now:
173
+ raise AdminError(f"尝试次数过多,请 {limit['locked_until'] - now} 秒后再试", 429)
174
+ if isinstance(password, str) and verify_password(password, row["password_hash"]):
175
+ connection.execute("DELETE FROM admin_attempts WHERE source=?", (source,))
176
+ return
177
+ failed = (attempts["failed"] if attempts else 0) + 1
178
+ lock = 0 if failed % FAILURES_PER_LOCK else min(LOCK_SECONDS * 2 ** (failed // FAILURES_PER_LOCK - 1), LOCK_MAX_SECONDS)
179
+ total = every["failed"] + 1 if every and now - every["updated_at"] <= GLOBAL_WINDOW else 1
180
+ connection.execute("INSERT OR REPLACE INTO admin_attempts (source,failed,locked_until,updated_at) VALUES (?,?,?,?)",
181
+ (source, failed, now + lock if lock else 0, now))
182
+ connection.execute("INSERT OR REPLACE INTO admin_attempts (source,failed,locked_until,updated_at) VALUES ('*',?,?,?)",
183
+ (0 if total >= GLOBAL_FAILURES else total, now + GLOBAL_WINDOW if total >= GLOBAL_FAILURES else 0,
184
+ now if total == 1 else every["updated_at"]))
147
185
  connection.commit()
186
+ raise AdminError("用户名或密码错误", 401)
148
187
 
149
188
 
150
- def check_not_locked(row, now):
151
- """Reject attempts while the account is locked, even with a correct password."""
152
- if row["locked_until"] > now:
153
- raise AdminError(f"尝试次数过多,请 {row['locked_until'] - now} 秒后再试", 429)
154
-
155
-
156
- def login_admin(connection, payload, now):
157
- """Verify the account and open a session; failures are generic and rate limited."""
189
+ def login_admin(connection, payload, now, source="unknown", label=""):
190
+ """Verify the account and open a login; failures are generic and rate limited per address."""
158
191
  begin_write(connection)
159
192
  row = admin_row(connection)
160
193
  username, password = payload.get("username"), payload.get("password")
161
194
  if not row or not isinstance(username, str) or not isinstance(password, str):
162
195
  raise AdminError("用户名或密码错误", 401)
163
- check_not_locked(row, now)
164
- if username.strip().lower() != row["username"].lower() or not verify_password(password, row["password_hash"]):
165
- record_failure(connection, row)
166
- raise AdminError("用户名或密码错误", 401)
167
- connection.execute("UPDATE site_admin SET failed_attempts=0, locked_until=0 WHERE id=1")
168
- return {"token": open_session(connection, now), "profile": profile_of(row)}
196
+ # The username is checked after the lock so a wrong name and a wrong password are indistinguishable.
197
+ check_password(connection, row, password if username.strip().lower() == row["username"].lower() else None, source, now)
198
+ return {"token": open_session(connection, now, label), "profile": profile_of(row)}
199
+
200
+
201
+ def list_sessions(connection, token, now):
202
+ """List live logins with their browser label; the id is a hash prefix, useless for signing in."""
203
+ current = live_session(connection, token, now)
204
+ if not current:
205
+ raise AdminError("需要登录管理员账号", 401)
206
+ rows = connection.execute("SELECT * FROM admin_logins WHERE last_seen>=? AND created_at>=? ORDER BY last_seen DESC",
207
+ (now - SESSION_IDLE_SECONDS, now - SESSION_MAX_SECONDS)).fetchall()
208
+ return {"sessions": [{"id": row["token_hash"][:16], "label": row["label"], "created_at": row["created_at"], "last_seen": row["last_seen"],
209
+ "current": row["token_hash"] == current["token_hash"]} for row in rows]}
210
+
211
+
212
+ def revoke_sessions(connection, token, payload, now):
213
+ """End one other login by id, or every login but this one."""
214
+ current = live_session(connection, token, now)
215
+ if not current:
216
+ raise AdminError("需要登录管理员账号", 401)
217
+ if payload.get("others") is True:
218
+ connection.execute("DELETE FROM admin_logins WHERE token_hash<>?", (current["token_hash"],))
219
+ return {"ok": True}
220
+ target = payload.get("id")
221
+ if not isinstance(target, str) or len(target) != 16 or any(char not in "0123456789abcdef" for char in target):
222
+ raise ValueError("须提供要退出的登录 id,或 others: true")
223
+ connection.execute("DELETE FROM admin_logins WHERE substr(token_hash,1,16)=? AND token_hash<>?", (target, current["token_hash"]))
224
+ return {"ok": True}
225
+
226
+
227
+ def change_password(connection, token, payload, now, source="unknown"):
228
+ """Change the password after proving the current one; every other login ends."""
229
+ begin_write(connection)
230
+ current = live_session(connection, token, now)
231
+ if not current:
232
+ raise AdminError("需要登录管理员账号", 401)
233
+ password = valid_password(payload.get("password"))
234
+ check_password(connection, admin_row(connection), payload.get("current_password"), source, now)
235
+ connection.execute("UPDATE site_admin SET password_hash=? WHERE id=1", (hash_password(password),))
236
+ connection.execute("DELETE FROM admin_logins WHERE token_hash<>?", (current["token_hash"],))
237
+ return {"ok": True}
169
238
 
170
239
 
171
240
  def logout_admin(connection, token):
172
- """Remove the current session; succeeds even without one."""
173
- connection.execute("DELETE FROM admin_sessions WHERE token_hash=?", (session_hash(token),))
241
+ """Remove the current login; succeeds even without one."""
242
+ connection.execute("DELETE FROM admin_logins WHERE token_hash=?", (session_hash(token),))
174
243
  return {"ok": True}
175
244
 
176
245