${referenceRows()}${linkedSection()}${historySection()}`;
footer.innerHTML=`${k?.status==='archived'?'':k?'':''}${k&&(k.kind==='preference'||k.status==='archived')?'':''}${k&&k.status==='draft'&&k.scope!=='unknown'&&k.quality==='useful'?'':''}${k&&k.quality!=='noise'?'':''}`;
+ details.querySelectorAll('[data-restore-version]').forEach(b=>b.onclick=()=>busy(b,async()=>{
+ const version=Number(b.dataset.restoreVersion);
+ if(!confirm(`回退至 v${version}?该版本将被复制为新的当前版本,原有变更历史不会删除。`))return;
+ try{
+ const r=await api(`knowledge/${k.id}/versions/${version}/restore`,{method:'POST'});
+ closeOverlay(true);
+ notify(`已回退至 v${version},创建新版本 v${r.version}`);
+ await go('knowledge',true);
+ await openKnowledge(k.id);
+ }catch(e){notify(e.message)}
+ }));
details.querySelectorAll('[data-open-knowledge]').forEach(b=>b.onclick=()=>openKnowledge(Number(b.dataset.openKnowledge)));
details.querySelectorAll('.markdown-body a[href^="#knowledge-"]').forEach(a=>a.onclick=e=>{e.preventDefault();openKnowledge(Number(a.getAttribute('href').slice(11)))});
el('edit-entry').onclick=edit;
From a49754150f0d53af4f59c035b94071fb80599ac2 Mon Sep 17 00:00:00 2001
From: KirenHu <154485441+KirenHu@users.noreply.github.com>
Date: Sat, 10 Oct 2026 13:50:44 +0800
Subject: [PATCH 02/53] feat(projects): add incremental, evidence-driven
historical project reassignment
---
.../worktwin/project_recheck.py | 146 ++++++++++++++++++
1 file changed, 146 insertions(+)
create mode 100644 worktwin-collector/worktwin/project_recheck.py
diff --git a/worktwin-collector/worktwin/project_recheck.py b/worktwin-collector/worktwin/project_recheck.py
new file mode 100644
index 00000000..afe9a9cc
--- /dev/null
+++ b/worktwin-collector/worktwin/project_recheck.py
@@ -0,0 +1,146 @@
+"""Low-priority project-identity rechecks as new authorized evidence appears.
+
+Fresh knowledge may make an older session identifiable. This worker revisits
+previously extracted work units, not whole folders. It never creates project
+permissions; existing twin/MCP permissions are resolved dynamically.
+"""
+from __future__ import annotations
+
+import hashlib
+import json
+
+from .projects import catalog, normalized_name, source_anchors
+from .scope import snapshot_history
+
+
+class ProjectRechecker:
+ def __init__(self, db, decision_router):
+ self.db = db
+ self.judge = decision_router
+
+ @staticmethod
+ def _signature(projects):
+ # Effective project evidence, not scanning time, triggers rechecking.
+ stable = [(p['project_key'],p['name'],p.get('anchor',''),p.get('summary',''))
+ for p in projects]
+ return hashlib.sha256(json.dumps(stable,ensure_ascii=False,sort_keys=True)
+ .encode('utf-8')).hexdigest()
+
+ def process_next(self, limit=12):
+ """Process one bounded batch, automatically continuing on idle cycles."""
+ with self.db.connect() as con:
+ projects=catalog(con)
+ signature=self._signature(projects)
+ old=con.execute("SELECT value FROM settings WHERE key='project_recheck_signature'").fetchone()
+ cursor=con.execute("SELECT value FROM settings WHERE key='project_recheck_cursor'").fetchone()
+ if not old or old['value']!=signature:
+ position=0
+ con.execute("""INSERT INTO settings(key,value) VALUES('project_recheck_signature',?)
+ ON CONFLICT(key) DO UPDATE SET value=excluded.value""",(signature,))
+ else:
+ position=int(cursor['value']) if cursor else 0
+ if position<0:
+ return {'state':'idle','checked':0,'updated':0}
+ rows=[dict(r) for r in con.execute("""
+ SELECT w.id,w.document_id,w.quote_hash,w.topic,w.project_hint,
+ w.project_key,w.status,w.evidence_json,
+ d.title,d.content,d.sha256,d.project_verified,d.deleted,
+ s.enabled source_enabled,s.allow_ai
+ FROM work_units w
+ JOIN documents d ON d.id=w.document_id
+ JOIN sources s ON s.id=d.source_id
+ WHERE w.id>? ORDER BY w.id LIMIT ?""",(position,limit))]
+ new_position=rows[-1]['id'] if len(rows)==limit else -1
+ con.execute("""INSERT INTO settings(key,value) VALUES('project_recheck_cursor',?)
+ ON CONFLICT(key) DO UPDATE SET value=excluded.value""",(str(new_position),))
+ if not rows:
+ return {'state':'idle','checked':0,'updated':0}
+
+ updated=0
+ for work in rows:
+ if (work['deleted'] or not work['source_enabled'] or not work['allow_ai']
+ or work['project_verified']):
+ continue
+ evidence=json.loads(work['evidence_json'])
+ quote=evidence.get('quote','')
+ if not quote or quote not in work['content']:
+ continue
+ anchors=source_anchors(quote)
+ hint=str(work['project_hint'] or '').strip()
+ # Only independently grounded source clues or an explicit name
+ # may nominate a previously known project.
+ proposals=[p for p in projects
+ if p['project_key']!=work['project_key'] and
+ ((hint and normalized_name(p['name'])==normalized_name(hint)) or
+ (p.get('anchor_type')=='repo' and p.get('anchor') in anchors['repositories']))]
+ matches=[]
+ for target in proposals[:5]:
+ repo=target.get('anchor','') if target.get('anchor_type')=='repo' else ''
+ if repo and anchors['repositories'] and repo not in anchors['repositories']:
+ continue
+ exact=bool(repo and repo in anchors['repositories'] and hint
+ and normalized_name(target['name'])==normalized_name(hint))
+ if work['status']=='confirmed' and not exact:
+ # Reassign a confirmed automatic project only with new
+ # independent identifier evidence, not name similarity.
+ continue
+ if exact:
+ result={'same':1.0,'conflict':0.0,'engine':'repo-anchor'}
+ else:
+ result=self.judge.compare(
+ {'name':hint,'quote':quote[:700],'topic':work['topic'],
+ 'repositories':anchors['repositories']},
+ {'name':target['name'],'project_key':target['project_key'],
+ 'anchor':repo,'existing_work':target.get('summary','')})
+ if result['same']>=.92 and result['conflict']<=.08:
+ matches.append((target,result))
+ if len(matches)!=1:
+ continue
+ target,result=matches[0]
+ with self.db.connect() as con:
+ con.execute("BEGIN IMMEDIATE")
+ current=con.execute("""SELECT w.project_key,w.status,d.sha256,d.content,
+ d.deleted,d.project_verified,s.enabled,s.allow_ai FROM work_units w
+ JOIN documents d ON d.id=w.document_id
+ JOIN sources s ON s.id=d.source_id WHERE w.id=?""",(work['id'],)).fetchone()
+ if (not current or current['project_key']!=work['project_key']
+ or current['status']!=work['status']
+ or current['sha256']!=work['sha256'] or quote not in current['content']
+ or current['deleted'] or current['project_verified']
+ or not current['enabled'] or not current['allow_ai']):
+ continue
+ # Do not rewrite owner-edited or multi-source knowledge. An
+ # identity change must never silently absorb another source.
+ linked=[dict(n) for n in con.execute("""SELECT DISTINCT k.*
+ FROM knowledge k JOIN knowledge_evidence e ON e.knowledge_id=k.id
+ WHERE e.document_id=? AND e.quote=? AND k.project_key=?""",
+ (work['document_id'],quote,work['project_key']))]
+ if any(k['created_by']=='human' or k['scope']=='global' or
+ con.execute("""SELECT 1 FROM knowledge_evidence
+ WHERE knowledge_id=? AND document_id!=? LIMIT 1""",
+ (k['id'],work['document_id'])).fetchone() for k in linked):
+ continue
+ fresh_evidence=dict(evidence)
+ fresh_evidence['reason']='新项目证据重新确认:'+result['engine']
+ con.execute("""UPDATE work_units SET project_key=?,status='confirmed',
+ confidence=?,evidence_json=?,updated_at=datetime('now') WHERE id=?""",
+ (target['project_key'],result['same'],
+ json.dumps(fresh_evidence,ensure_ascii=False),work['id']))
+ for note in linked:
+ snapshot_history(con,note)
+ con.execute("""UPDATE knowledge SET project_key=?,project=?,
+ scope='project',version=version+1,updated_at=datetime('now')
+ WHERE id=?""",(target['project_key'],target['name'],note['id']))
+ con.execute("""INSERT INTO project_memberships
+ (knowledge_id,project_key,confidence,status,reason)
+ VALUES(?,?,?,'confirmed',?)
+ ON CONFLICT(knowledge_id) DO UPDATE SET
+ project_key=excluded.project_key,
+ confidence=excluded.confidence,status='confirmed',
+ reason=excluded.reason,updated_at=datetime('now')""",
+ (note['id'],target['project_key'],result['same'],
+ fresh_evidence['reason']))
+ updated+=1
+ if updated:
+ self.db.event('project_rechecked',f'依据新增证据自动更新 {updated} 个工作事项的项目归属')
+ return {'state':'processed','checked':len(rows),'updated':updated}
From c2ffbe29ae9f56140ea653a41c30d35c39cdbdf0 Mon Sep 17 00:00:00 2001
From: KirenHu <154485441+KirenHu@users.noreply.github.com>
Date: Sat, 10 Oct 2026 13:51:15 +0800
Subject: [PATCH 03/53] test(projects): schedule project recheck and verify
automatic twin grant updates
---
.../tests/test_project_autonomy.py | 77 +++++++++++++++++++
worktwin-collector/worktwin/jobs.py | 6 ++
2 files changed, 83 insertions(+)
diff --git a/worktwin-collector/tests/test_project_autonomy.py b/worktwin-collector/tests/test_project_autonomy.py
index adef6263..b5a4020a 100644
--- a/worktwin-collector/tests/test_project_autonomy.py
+++ b/worktwin-collector/tests/test_project_autonomy.py
@@ -6,6 +6,7 @@
from worktwin.api import create_app
from worktwin.db import Database
from worktwin.projects import catalog, plan_work_units, apply_assignments
+from worktwin.project_recheck import ProjectRechecker
from worktwin.knowledge import store_candidates
from worktwin.reconcile import existing_for_project
from worktwin.twin_access import effective_notes
@@ -192,3 +193,79 @@ def test_document_title_never_supplies_identity_to_unrelated_work_unit(tmp_path)
'anchor':'alpha-team/atlas-one','summary':'another work unit'}],Judge())
assert plan[0]['scope']=='session'
assert plan[0]['project_key']!= 'auto:other'
+
+
+def test_new_project_evidence_recovers_isolated_work_and_updates_twin_grants(tmp_path):
+ db=Database(tmp_path/'db.sqlite')
+ quote='WorkTwin 项目当前使用独立的知识维护流程。'
+ other='WorkTwin 项目之后明确采用了同一知识维护方案。'
+ with db.connect() as con:
+ old_source,old_doc=create_source(con,'earlier','one.md',quote)
+ con.execute("INSERT INTO twins(name,knowledge_mode) VALUES('交接分身','dynamic')")
+ twin_id=con.execute('SELECT id FROM twins').fetchone()[0]
+ note=con.execute("""INSERT INTO knowledge(kind,title,body,status,created_by,
+ source_bound,scope,project_key,project,topic,quality)
+ VALUES('decision','归属待定',?,'confirmed','enterprise_ai',1,'session',?,
+ 'WorkTwin','工作流程','useful')""",(quote,'session:'+str(old_doc))).lastrowid
+ con.execute("INSERT INTO knowledge_evidence(knowledge_id,document_id,quote) VALUES(?,?,?)",
+ (note,old_doc,quote))
+ import hashlib,json
+ con.execute("""INSERT INTO work_units(document_id,quote_hash,topic,project_hint,
+ project_key,status,evidence_json) VALUES(?,?,?,?,?,'isolated',?)""",
+ (old_doc,hashlib.sha256(quote.encode()).hexdigest(),'工作流程',
+ 'WorkTwin','session:'+str(old_doc),json.dumps({'quote':quote})))
+ _,new_doc=create_source(con,'later','two.md',other)
+ key='auto:known-worktwin'
+ con.execute("INSERT INTO project_entities(project_key,name) VALUES(?,'WorkTwin')",(key,))
+ con.execute("""INSERT INTO knowledge(kind,title,body,status,source_bound,
+ created_by,scope,project_key,project,topic,quality)
+ VALUES('decision','后续工作',?,'confirmed',1,'enterprise_ai',
+ 'project',?,'WorkTwin','工作流程','useful')""",(other,key))
+ fresh=con.execute('SELECT MAX(id) FROM knowledge').fetchone()[0]
+ con.execute("INSERT INTO knowledge_evidence(knowledge_id,document_id,quote) VALUES(?,?,?)",
+ (fresh,new_doc,other))
+ con.execute("""INSERT INTO twin_grants(twin_id,subject_type,subject_key)
+ VALUES(?,'project',?)""",(twin_id,key))
+ assert note not in {k['id'] for k in effective_notes(con,twin_id)}
+ judge=Judge()
+ rechecker=ProjectRechecker(db,judge)
+ result=rechecker.process_next()
+ assert result['updated']==1 and judge.calls>=1
+ with db.connect() as con:
+ revised=con.execute('SELECT * FROM knowledge WHERE id=?',(note,)).fetchone()
+ assert revised['project_key']==key and revised['scope']=='project'
+ assert revised['version']==2
+ assert note in {k['id'] for k in effective_notes(con,twin_id)}
+ assert con.execute("SELECT count(*) FROM knowledge_history WHERE knowledge_id=?",
+ (note,)).fetchone()[0]==1
+ assert rechecker.process_next()['state']=='idle'
+
+
+def test_project_recheck_does_not_reassign_owner_notes_or_revoked_sources(tmp_path):
+ db=Database(tmp_path/'db.sqlite')
+ quote='WorkTwin 项目讨论已明确,但人工写的结论不应自动迁移。'
+ with db.connect() as con:
+ source,doc=create_source(con,'older','one.md',quote)
+ import hashlib,json
+ con.execute("""INSERT INTO work_units(document_id,quote_hash,topic,project_hint,
+ project_key,status,evidence_json) VALUES(?,?,?,?,?,'isolated',?)""",
+ (doc,hashlib.sha256(quote.encode()).hexdigest(),'规则',
+ 'WorkTwin','session:'+str(doc),json.dumps({'quote':quote})))
+ note=con.execute("""INSERT INTO knowledge(kind,title,body,status,created_by,
+ source_bound,scope,project_key,quality)
+ VALUES('decision','人工维护',?,'confirmed','human',1,'session',?,'useful')""",
+ (quote,'session:'+str(doc))).lastrowid
+ con.execute("INSERT INTO knowledge_evidence(knowledge_id,document_id,quote) VALUES(?,?,?)",
+ (note,doc,quote))
+ _,did2=create_source(con,'next','two.md','WorkTwin 项目的后续证据。')
+ con.execute("INSERT INTO project_entities(project_key,name) VALUES('auto:known','WorkTwin')")
+ other=con.execute("""INSERT INTO knowledge(kind,title,body,status,created_by,source_bound,
+ scope,project,project_key,quality) VALUES('fact','新证据','新证据','confirmed',
+ 'enterprise_ai',1,'project','WorkTwin','auto:known','useful')""").lastrowid
+ con.execute("INSERT INTO knowledge_evidence(knowledge_id,document_id,quote) VALUES(?,?,?)",
+ (other,did2,'WorkTwin 项目的后续证据。'))
+ assert ProjectRechecker(db,Judge()).process_next()['updated']==0
+ with db.connect() as con:
+ assert con.execute("SELECT project_key FROM knowledge WHERE id=?",(note,)).fetchone()[0]=='session:'+str(doc)
+ con.execute("UPDATE sources SET allow_ai=0 WHERE id=?",(source,))
+ assert ProjectRechecker(db,Judge()).process_next()['updated']==0
diff --git a/worktwin-collector/worktwin/jobs.py b/worktwin-collector/worktwin/jobs.py
index 62295f70..af04d5bd 100644
--- a/worktwin-collector/worktwin/jobs.py
+++ b/worktwin-collector/worktwin/jobs.py
@@ -13,6 +13,7 @@
from .gardener import KnowledgeGardener
from .scope import document_scope
from .projects import catalog, plan_work_units, apply_assignments
+from .project_recheck import ProjectRechecker
from .decision_model import DecisionRouter
from .credentials import DesktopSecrets
from .automation import activate_new, apply_additions, model_signature
@@ -30,6 +31,7 @@ def __init__(self, db: Database, *, client: GatewayClient | None = None, interva
self.processing = False
self.gardener = KnowledgeGardener(db, client=self.client)
self.decision_router=decision_router or DecisionRouter(db,DesktopSecrets(db.path),self.client)
+ self.project_rechecker=ProjectRechecker(db,self.decision_router)
def start(self):
if self._thread and self._thread.is_alive():
@@ -64,6 +66,10 @@ def _loop(self):
# Maintenance is lower priority than fresh capture: one note
# per idle cycle and no call if extraction is still backed up.
if result == "idle" and not self._stop.is_set():
+ # A new project may resolve previously isolated sessions.
+ # Work through a bounded batch before lower priority text
+ # curation; no user confirmation or full-corpus re-extraction.
+ self.project_rechecker.process_next()
self.gardener.client = self.client
self.gardener.process_next()
From 24d6ae5545bc99b9dba0d7e2defcd0c651e59c6d Mon Sep 17 00:00:00 2001
From: KirenHu <154485441+KirenHu@users.noreply.github.com>
Date: Sat, 10 Oct 2026 13:52:24 +0800
Subject: [PATCH 04/53] feat(browser): add explicit site-scoped manual
recording session controls
---
.../worktwin/browser_capture_manual.py | 100 ++++++++++++++++++
1 file changed, 100 insertions(+)
create mode 100644 worktwin-collector/worktwin/browser_capture_manual.py
diff --git a/worktwin-collector/worktwin/browser_capture_manual.py b/worktwin-collector/worktwin/browser_capture_manual.py
new file mode 100644
index 00000000..3146e5d7
--- /dev/null
+++ b/worktwin-collector/worktwin/browser_capture_manual.py
@@ -0,0 +1,100 @@
+"""Explicit local opt-in browser recording, independent of workflow tickets.
+
+One requested site, one bound browser tab and a bounded session. The manually
+recorded session uses the same redacted event/summary storage as task capture.
+"""
+from __future__ import annotations
+
+import secrets
+import time
+from urllib.parse import urlsplit
+
+from fastapi import HTTPException
+
+
+def manual_site(value: str) -> str:
+ raw=str(value or '').strip()
+ if raw and '://' not in raw:
+ raw='https://'+raw
+ try:
+ url=urlsplit(raw)
+ host=(url.hostname or '').lower()
+ if not host or url.username or url.password or url.scheme not in ('https','http'):
+ raise ValueError()
+ if url.scheme=='http' and host not in ('127.0.0.1','localhost'):
+ raise ValueError()
+ if host in ('::1',) or host.endswith('.localhost'):
+ raise ValueError()
+ port=url.port
+ if port is not None and not 1<=port<=65535:
+ raise ValueError()
+ return f"{url.scheme}://{host}"+(f":{port}" if port else '')
+ except ValueError:
+ raise HTTPException(400,'请输入 HTTPS 网站地址;本地测试可使用 http://localhost')
+
+
+def manual_page(url: str) -> str:
+ site=manual_site(url)
+ parsed=urlsplit(url)
+ return site+(parsed.path or '/')
+
+
+class ManualCapture:
+ def manual_config(self, *, enabled: bool, url: str = '') -> dict:
+ target=manual_site(url) if enabled else ''
+ now=int(time.time())
+ with self.db.connect() as con:
+ con.execute('BEGIN IMMEDIATE')
+ if enabled and not self.enabled(con):
+ raise HTTPException(409,'请先启用浏览器行为采集总开关')
+ row=con.execute("SELECT manual_enabled,manual_site,manual_session_id FROM browser_capture_settings WHERE id=1").fetchone()
+ if enabled and row['manual_enabled'] and row['manual_site']==target:
+ session=con.execute("SELECT status,expires_at FROM browser_capture_sessions WHERE id=?",
+ (row['manual_session_id'],)).fetchone()
+ if session and session['status'] in ('armed','capturing') and session['expires_at']>now:
+ return self.settings()
+ if row['manual_session_id']:
+ con.execute("""UPDATE browser_capture_sessions SET status='completed',ended_at=?
+ WHERE id=? AND mode='manual' AND status IN ('armed','capturing')""",
+ (now,row['manual_session_id']))
+ if enabled:
+ sid=secrets.token_urlsafe(20)
+ con.execute("""INSERT INTO browser_capture_sessions
+ (id,task_id,page_key,launcher_origin,mode,status,created_at,expires_at)
+ VALUES(?,?,?,'manual','manual','armed',?,?)""",
+ (sid,'manual-'+secrets.token_hex(8),target,now,now+8*3600))
+ con.execute("""UPDATE browser_capture_settings SET manual_site=?,
+ manual_session_id=?,manual_enabled=1 WHERE id=1""",(target,sid))
+ else:
+ con.execute("""UPDATE browser_capture_settings SET
+ manual_enabled=0,manual_site='',manual_session_id='' WHERE id=1""")
+ return self.settings()
+
+ def manual_current(self, token: str) -> dict:
+ with self.db.connect() as con:
+ self._extension(con,token)
+ row=con.execute("""SELECT b.manual_enabled,b.manual_site,b.manual_session_id,
+ s.status,s.expires_at FROM browser_capture_settings b
+ LEFT JOIN browser_capture_sessions s ON s.id=b.manual_session_id
+ WHERE b.id=1""").fetchone()
+ if not row['manual_enabled'] or row['status'] not in ('armed','capturing') or row['expires_at']<=time.time():
+ return {'enabled':False}
+ return {'enabled':True,'session_id':row['manual_session_id'],
+ 'site':row['manual_site'],'status':row['status']}
+
+ def manual_rebind(self, token: str, session_id: str, tab_id: int,
+ document_id: str, current_url: str) -> dict:
+ target=manual_site(current_url)
+ with self.db.connect() as con:
+ con.execute('BEGIN IMMEDIATE')
+ self._extension(con,token)
+ row=con.execute("""SELECT s.* FROM browser_capture_sessions s
+ JOIN browser_capture_settings b ON b.manual_session_id=s.id
+ WHERE s.id=? AND b.manual_enabled=1""",(session_id,)).fetchone()
+ if (not row or row['mode']!='manual' or row['status']!='capturing'
+ or row['expires_at']<=time.time() or row['tab_id']!=tab_id
+ or target!=row['page_key'] or not document_id):
+ raise HTTPException(409,'手动采集已结束或页面不在指定网站内')
+ con.execute("""UPDATE browser_capture_sessions SET document_id=?
+ WHERE id=?""",(document_id,session_id))
+ return {'status':'capturing'}
From 7622e7018c55a64e19d526e7a3e8a4053913097a Mon Sep 17 00:00:00 2001
From: KirenHu <154485441+KirenHu@users.noreply.github.com>
Date: Sat, 10 Oct 2026 13:53:09 +0800
Subject: [PATCH 05/53] feat(browser): persist manual capture and collect only
the specified site and bound tab
---
.../worktwin/browser_capture.py | 110 +++++++++++++-----
1 file changed, 79 insertions(+), 31 deletions(-)
diff --git a/worktwin-collector/worktwin/browser_capture.py b/worktwin-collector/worktwin/browser_capture.py
index b437714c..ffb887e9 100644
--- a/worktwin-collector/worktwin/browser_capture.py
+++ b/worktwin-collector/worktwin/browser_capture.py
@@ -22,6 +22,7 @@
from .browser_capture_status import (
MAX_STATUS_FAILURES, TASK_STATUS_TTL_SECONDS, validate_status_path,
)
+from .browser_capture_manual import ManualCapture, manual_site, manual_page
SESSION_TTL_SECONDS = 8 * 60 * 60
PAIR_TTL_SECONDS = 5 * 60
@@ -62,7 +63,7 @@ def valid_label(value: object) -> str:
return value
-class BrowserCapture:
+class BrowserCapture(ManualCapture):
def __init__(self, db):
self.db = db
with db.connect() as con:
@@ -70,7 +71,10 @@ def __init__(self, db):
CREATE TABLE IF NOT EXISTS browser_capture_settings(
id INTEGER PRIMARY KEY CHECK(id=1),
enabled INTEGER NOT NULL DEFAULT 0,
- allow_ai INTEGER NOT NULL DEFAULT 0);
+ allow_ai INTEGER NOT NULL DEFAULT 0,
+ manual_enabled INTEGER NOT NULL DEFAULT 0,
+ manual_site TEXT NOT NULL DEFAULT '',
+ manual_session_id TEXT NOT NULL DEFAULT '');
INSERT OR IGNORE INTO browser_capture_settings(id,enabled) VALUES(1,0);
CREATE TABLE IF NOT EXISTS browser_extension_pairing(
id INTEGER PRIMARY KEY CHECK(id=1), code_hash TEXT NOT NULL,
@@ -81,6 +85,7 @@ def __init__(self, db):
CREATE TABLE IF NOT EXISTS browser_capture_sessions(
id TEXT PRIMARY KEY, task_id TEXT NOT NULL, page_key TEXT NOT NULL,
launcher_origin TEXT NOT NULL, tab_id INTEGER, document_id TEXT,
+ mode TEXT NOT NULL DEFAULT 'task',
status TEXT NOT NULL, created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL, ended_at INTEGER, last_seq INTEGER NOT NULL DEFAULT 0,
event_count INTEGER NOT NULL DEFAULT 0);
@@ -118,6 +123,21 @@ def __init__(self, db):
cols={r[1] for r in con.execute("PRAGMA table_info(browser_capture_settings)")}
if "allow_ai" not in cols:
con.execute("ALTER TABLE browser_capture_settings ADD COLUMN allow_ai INTEGER NOT NULL DEFAULT 0")
+ for field,definition in (
+ ('manual_enabled',"INTEGER NOT NULL DEFAULT 0"),
+ ('manual_site',"TEXT NOT NULL DEFAULT ''"),
+ ('manual_session_id',"TEXT NOT NULL DEFAULT ''")):
+ if field not in cols:
+ con.execute(f"ALTER TABLE browser_capture_settings ADD COLUMN {field} {definition}")
+ session_cols={r[1] for r in con.execute("PRAGMA table_info(browser_capture_sessions)")}
+ if 'mode' not in session_cols:
+ con.execute("ALTER TABLE browser_capture_sessions ADD COLUMN mode TEXT NOT NULL DEFAULT 'task'")
+ # Manual capture must be deliberately started again after restart.
+ con.execute("""UPDATE browser_capture_sessions SET status='disabled',
+ ended_at=? WHERE mode='manual' AND status IN ('armed','capturing')""",
+ (int(time.time()),))
+ con.execute("""UPDATE browser_capture_settings SET manual_enabled=0,
+ manual_session_id='',manual_site='' WHERE id=1""")
def enabled(self, con) -> bool:
return bool(con.execute("SELECT enabled FROM browser_capture_settings WHERE id=1").fetchone()[0])
@@ -128,12 +148,16 @@ def settings(self) -> dict:
client = con.execute("SELECT last_seen FROM browser_extension_clients WHERE id=1").fetchone()
sessions = con.execute("""SELECT count(*) FROM
(SELECT task_id,launcher_origin FROM browser_capture_sessions
- WHERE status IN ('armed','capturing','navigation_stopped') AND expires_at>?
+ WHERE mode='task' AND status IN ('armed','capturing','navigation_stopped') AND expires_at>?
GROUP BY task_id,launcher_origin)""", (now,)).fetchone()[0]
- return {"enabled":self.enabled(con),"allow_ai":bool(con.execute(
- "SELECT allow_ai FROM browser_capture_settings WHERE id=1").fetchone()[0]),
+ flags=con.execute("""SELECT allow_ai,manual_enabled,manual_site,manual_session_id
+ FROM browser_capture_settings WHERE id=1""").fetchone()
+ return {"enabled":self.enabled(con),"allow_ai":bool(flags['allow_ai']),
"extension_connected":bool(client and now-int(client["last_seen"])<=90),
"active_tasks":sessions,
+ "manual_enabled":bool(flags['manual_enabled']),
+ "manual_site":flags['manual_site'],
+ "manual_session_id":flags['manual_session_id'],
"flow_configured":bool(os.environ.get("WORKTWIN_CAPTURE_FLOW_ORIGIN") and
os.environ.get("WORKTWIN_CAPTURE_FLOW_SECRET"))}
@@ -146,7 +170,8 @@ def toggle(self, enabled: bool):
con.execute("DELETE FROM browser_extension_pairing")
# Existing extension may re-pair when the user next enables capture.
con.execute("DELETE FROM browser_extension_clients")
- con.execute("UPDATE browser_capture_settings SET allow_ai=0 WHERE id=1")
+ con.execute("""UPDATE browser_capture_settings SET
+ allow_ai=0,manual_enabled=0,manual_site='',manual_session_id='' WHERE id=1""")
con.execute("UPDATE browser_capture_tasks SET state='disabled' WHERE state='active'")
return self.settings()
@@ -351,12 +376,14 @@ def extension_sessions(self, token: str, session_ids: list[str]):
def bind(self, token: str, session_id: str, tab_id: int, document_id: str, current_url: str):
if tab_id<0 or not re.fullmatch(r"[A-Za-z0-9_-]{1,100}",document_id):
raise HTTPException(400,"标签页或文档标识无效")
- key=page_key(current_url)
with self.db.connect() as con:
con.execute("BEGIN IMMEDIATE")
self._extension(con,token)
r=con.execute("SELECT * FROM browser_capture_sessions WHERE id=?",(session_id,)).fetchone()
- if not r or r["status"]!="armed" or r["expires_at"]<=time.time() or r["page_key"]!=key:
+ if not r or r["status"]!="armed" or r["expires_at"]<=time.time():
+ raise HTTPException(409,"采集会话已结束或尚未启动")
+ key=manual_site(current_url) if r['mode']=='manual' else page_key(current_url)
+ if r["page_key"]!=key:
raise HTTPException(409,"目标网页与有效待采集任务不匹配")
con.execute("""UPDATE browser_capture_sessions SET tab_id=?,document_id=?,status='capturing'
WHERE id=?""",(tab_id,document_id,session_id))
@@ -367,8 +394,6 @@ def event(self, token: str, session_id: str, seq: int, kind: str,
if kind not in ALLOWED_ACTIONS or seq<1 or seq>1000000:
raise HTTPException(400,"事件类型或序号无效")
now=int(time.time())
- key=(page_key(current_url) if kind not in ("navigation","tab_closed") else
- (page_key(current_url) if origin(current_url) else "[离开 HTTPS 网页]"))
with self.db.connect() as con:
con.execute("BEGIN IMMEDIATE")
self._extension(con,token)
@@ -377,30 +402,45 @@ def event(self, token: str, session_id: str, seq: int, kind: str,
raise HTTPException(409,"采集会话已结束或尚未启动")
if r["tab_id"]!=tab_id or r["document_id"]!=document_id:
raise HTTPException(403,"非本次任务绑定的浏览器文档")
- if kind!="navigation" and key!=r["page_key"]:
- raise HTTPException(403,"页面已离开授权范围")
- if kind in ("navigation","tab_closed"):
- if seq!=r["last_seq"]+1:
- raise HTTPException(409,"事件序号不连续")
- con.execute("""INSERT INTO browser_capture_events
- (session_id,seq,at,kind,label,location) VALUES(?,?,?,?,?,?)""",
- (session_id,seq,now,kind,"页面导航,停止观察" if kind=="navigation" else "标签页关闭,停止观察",key))
- self._update_step(con,session_id,seq,kind,"")
- con.execute("""UPDATE browser_capture_sessions SET
- last_seq=?,event_count=event_count+1,status=?,ended_at=?
- WHERE id=?""",(seq,"navigation_stopped" if kind=="navigation" else "tab_closed",now,session_id))
- return {"ack":seq,"status":"navigation_stopped" if kind=="navigation" else "tab_closed"}
- if seq==r["last_seq"]:
+ manual=r['mode']=='manual'
+ if manual:
+ try:
+ allowed=manual_site(current_url)==r['page_key']
+ location=manual_page(current_url) if allowed else '[离开监控网站]'
+ except HTTPException:
+ allowed=False
+ location='[离开监控网站]'
+ if not allowed and kind not in ('navigation','tab_closed'):
+ raise HTTPException(403,"页面已离开手动授权的网站")
+ else:
+ location=(page_key(current_url) if kind not in ('navigation','tab_closed')
+ else (page_key(current_url) if origin(current_url) else '[离开 HTTPS 网页]'))
+ if kind!='navigation' and location!=r['page_key']:
+ raise HTTPException(403,"页面已离开授权范围")
+ if seq==r['last_seq'] and kind not in ('navigation','tab_closed'):
return {"ack":seq,"status":"duplicate"}
if seq!=r["last_seq"]+1:
raise HTTPException(409,"事件序号不连续")
+ stopped=kind=='tab_closed' or (kind=='navigation' and
+ (not manual or not allowed))
+ status=('tab_closed' if kind=='tab_closed' else 'navigation_stopped') if stopped else 'capturing'
+ event_label=('页面导航,继续观察' if manual and kind=='navigation' and allowed
+ else '页面导航,停止观察' if kind=='navigation'
+ else '标签页关闭,停止观察' if kind=='tab_closed'
+ else valid_label(label))
con.execute("""INSERT INTO browser_capture_events
(session_id,seq,at,kind,label,location) VALUES(?,?,?,?,?,?)""",
- (session_id,seq,now,kind,valid_label(label),key))
- self._update_step(con,session_id,seq,kind,valid_label(label))
+ (session_id,seq,now,kind,event_label,location))
+ self._update_step(con,session_id,seq,kind,event_label)
con.execute("""UPDATE browser_capture_sessions SET
- last_seq=?,event_count=event_count+1 WHERE id=?""",(seq,session_id))
- return {"ack":seq,"status":"capturing"}
+ last_seq=?,event_count=event_count+1,status=?,
+ ended_at=CASE WHEN ? THEN ? ELSE ended_at END
+ WHERE id=?""",(seq,status,int(stopped),now,session_id))
+ if manual and stopped:
+ con.execute("""UPDATE browser_capture_settings SET
+ manual_enabled=0,manual_site='',manual_session_id=''
+ WHERE id=1 AND manual_session_id=?""",(session_id,))
+ return {"ack":seq,"status":status}
@staticmethod
def _update_step(con, session_id: str, seq: int, kind: str, label: str):
@@ -426,8 +466,16 @@ def steps(self, session_id: str, limit: int=100):
action,status,label FROM browser_capture_steps
WHERE session_id=? ORDER BY step LIMIT ?""",(session_id,limit))]
- def recent(self, limit: int=30):
+ def recent(self, limit: int=30, offset: int=0):
with self.db.connect() as con:
- return [dict(x) for x in con.execute("""SELECT id,task_id,page_key,status,
+ return [dict(x) for x in con.execute("""SELECT id,task_id,page_key,mode,status,
event_count,created_at,ended_at FROM browser_capture_sessions
- ORDER BY created_at DESC,rowid DESC LIMIT ?""",(limit,))]
+ ORDER BY created_at DESC,rowid DESC LIMIT ? OFFSET ?""",(limit,offset))]
+
+ def events(self, session_id: str, limit: int=100, offset: int=0):
+ with self.db.connect() as con:
+ if not con.execute("SELECT 1 FROM browser_capture_sessions WHERE id=?",(session_id,)).fetchone():
+ raise HTTPException(404,"采集会话不存在")
+ return [dict(x) for x in con.execute("""SELECT seq,at,kind,label,location
+ FROM browser_capture_events WHERE session_id=?
+ ORDER BY seq LIMIT ? OFFSET ?""",(session_id,limit,offset))]
From 3409bda21013d66a524fb04b3a00385ecede5bd7 Mon Sep 17 00:00:00 2001
From: KirenHu <154485441+KirenHu@users.noreply.github.com>
Date: Sat, 10 Oct 2026 13:53:28 +0800
Subject: [PATCH 06/53] feat(browser): expose manual recording and full
per-session event timeline endpoints
---
worktwin-collector/worktwin/api.py | 32 ++++++++++++++++++++++++++++--
1 file changed, 30 insertions(+), 2 deletions(-)
diff --git a/worktwin-collector/worktwin/api.py b/worktwin-collector/worktwin/api.py
index 53b56319..1e07f24b 100644
--- a/worktwin-collector/worktwin/api.py
+++ b/worktwin-collector/worktwin/api.py
@@ -174,6 +174,16 @@ class CaptureToggle(BaseModel):
class CaptureAiToggle(BaseModel):
allow_ai: bool
+class ManualCaptureInput(BaseModel):
+ enabled: bool
+ url: str = Field(default='',max_length=2048)
+
+class ManualCaptureRebind(BaseModel):
+ session_id: str = Field(max_length=100)
+ tab_id: int
+ document_id: str = Field(max_length=100)
+ current_url: str = Field(max_length=2048)
+
class CapturePair(BaseModel):
code: str = Field(min_length=10,max_length=128)
@@ -337,6 +347,10 @@ def browser_capture_toggle(body: CaptureToggle):
def browser_capture_ai_toggle(body: CaptureAiToggle):
return browser_capture.toggle_ai(body.allow_ai)
+ @app.put("/api/browser-capture/manual",dependencies=[Depends(authorized)])
+ def configure_manual_capture(body: ManualCaptureInput):
+ return browser_capture.manual_config(enabled=body.enabled,url=body.url)
+
@app.get("/api/browser-capture/extension",dependencies=[Depends(authorized)])
def browser_capture_extension_download():
extension_dir = Path(__file__).parent / "browser_extension"
@@ -353,8 +367,14 @@ def browser_capture_pairing():
return {"code":browser_capture.pairing_code(),"valid_seconds":300}
@app.get("/api/browser-capture/sessions",dependencies=[Depends(authorized)])
- def browser_capture_sessions():
- return browser_capture.recent()
+ def browser_capture_sessions(limit: int = Query(30,ge=1,le=100),
+ offset: int = Query(0,ge=0)):
+ return browser_capture.recent(limit=limit,offset=offset)
+
+ @app.get("/api/browser-capture/sessions/{session_id}/events",dependencies=[Depends(authorized)])
+ def browser_capture_events(session_id: str,limit: int = Query(100,ge=1,le=200),
+ offset: int = Query(0,ge=0)):
+ return browser_capture.events(session_id,limit=limit,offset=offset)
@app.get("/api/browser-capture/sessions/{session_id}/steps",dependencies=[Depends(authorized)])
def browser_capture_steps(session_id: str):
@@ -386,6 +406,14 @@ def capture_command(request: Request,body: CaptureCommand):
# external workflow must also sign the complete, expiring command.
return browser_capture.command(capture_credential(request),body.sender_origin,body.envelope)
+ @app.post("/capture/manual/current")
+ def capture_manual_current(request: Request):
+ return browser_capture.manual_current(capture_credential(request))
+
+ @app.post("/capture/manual/rebind")
+ def capture_manual_rebind(request: Request,body: ManualCaptureRebind):
+ return browser_capture.manual_rebind(capture_credential(request),**body.model_dump())
+
@app.post("/capture/bind")
def capture_bind(request: Request,body: CaptureBind):
return browser_capture.bind(capture_credential(request),**body.model_dump())
From e0e1faf407910ae2376dc2c6ef5b0b1b4aa02a25 Mon Sep 17 00:00:00 2001
From: KirenHu <154485441+KirenHu@users.noreply.github.com>
Date: Sat, 10 Oct 2026 13:54:19 +0800
Subject: [PATCH 07/53] feat(browser): extend Chrome MV3 capture to explicit
manual site recording across same-site navigations
---
.../worktwin/browser_capture_manual.py | 7 +-
.../worktwin/browser_extension/manifest.json | 8 +-
.../worktwin/browser_extension/popup.html | 4 +-
.../worktwin/browser_extension/popup.js | 2 +-
.../browser_extension/service-worker.js | 76 +++++++++++++++++--
5 files changed, 83 insertions(+), 14 deletions(-)
diff --git a/worktwin-collector/worktwin/browser_capture_manual.py b/worktwin-collector/worktwin/browser_capture_manual.py
index 3146e5d7..39cc0695 100644
--- a/worktwin-collector/worktwin/browser_capture_manual.py
+++ b/worktwin-collector/worktwin/browser_capture_manual.py
@@ -74,13 +74,16 @@ def manual_current(self, token: str) -> dict:
with self.db.connect() as con:
self._extension(con,token)
row=con.execute("""SELECT b.manual_enabled,b.manual_site,b.manual_session_id,
- s.status,s.expires_at FROM browser_capture_settings b
+ s.status,s.expires_at,s.tab_id,s.document_id,s.last_seq
+ FROM browser_capture_settings b
LEFT JOIN browser_capture_sessions s ON s.id=b.manual_session_id
WHERE b.id=1""").fetchone()
if not row['manual_enabled'] or row['status'] not in ('armed','capturing') or row['expires_at']<=time.time():
return {'enabled':False}
return {'enabled':True,'session_id':row['manual_session_id'],
- 'site':row['manual_site'],'status':row['status']}
+ 'site':row['manual_site'],'status':row['status'],
+ 'tab_id':row['tab_id'],'document_id':row['document_id'],
+ 'last_seq':row['last_seq']}
def manual_rebind(self, token: str, session_id: str, tab_id: int,
document_id: str, current_url: str) -> dict:
diff --git a/worktwin-collector/worktwin/browser_extension/manifest.json b/worktwin-collector/worktwin/browser_extension/manifest.json
index d06244c2..d79e1c0e 100644
--- a/worktwin-collector/worktwin/browser_extension/manifest.json
+++ b/worktwin-collector/worktwin/browser_extension/manifest.json
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "WorkTwin · 任务触发式浏览器行为采集",
- "version": "0.2.0",
- "description": "仅在 WorkTwin 已开启且可信流程平台签发任务时采集目标页面的结构化操作事件",
+ "version": "0.3.0",
+ "description": "仅在明确开启手动网站监控或可信流程任务时,采集已授权页面的脱敏操作事件",
"permissions": [
"tabs",
"scripting",
@@ -14,7 +14,9 @@
"http://127.0.0.1/*"
],
"optional_host_permissions": [
- "https://*/*"
+ "https://*/*",
+ "http://localhost/*",
+ "http://127.0.0.1/*"
],
"externally_connectable": {
"matches": [
diff --git a/worktwin-collector/worktwin/browser_extension/popup.html b/worktwin-collector/worktwin/browser_extension/popup.html
index 7eba53b7..04a64864 100644
--- a/worktwin-collector/worktwin/browser_extension/popup.html
+++ b/worktwin-collector/worktwin/browser_extension/popup.html
@@ -3,5 +3,5 @@