terminal-ai-helper 2.3.2 → 2.3.4
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.
- package/VERSION +1 -1
- package/package.json +1 -1
- package/plugins/zsh/widgets.zsh +105 -4
- package/tai/cli.py +12 -6
- package/tai/engcache.py +35 -10
- package/tai/engine.py +22 -6
- package/tai/fresh.py +7 -2
- package/tai/index.py +7 -1
- package/tai/maintenance.py +9 -1
- package/tai/paths.py +91 -5
- package/tai/predictor.py +116 -12
- package/tai/spool.py +6 -3
- package/tai/store.py +26 -3
- package/tai/typo.py +41 -15
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.3.
|
|
1
|
+
2.3.4
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "terminal-ai-helper",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.4",
|
|
4
4
|
"description": "The terminal that knows your next command Bash/Zsh suggestions that learn from your own history Offline and private with no account required",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/plugins/zsh/widgets.zsh
CHANGED
|
@@ -29,6 +29,29 @@ typeset -g _TAI_PASTE_PREV=""
|
|
|
29
29
|
typeset -gi _TAI_PASTE_QUIET=0
|
|
30
30
|
typeset -gi _TAI_PASTE_JUMP=25
|
|
31
31
|
|
|
32
|
+
# The clock the chunk rule reads, the redraw that raised the quiet, and the
|
|
33
|
+
# query an arrival skipped. zsh/datetime is what makes $EPOCHREALTIME exist;
|
|
34
|
+
# on a zsh too old for it the chunk rule is inert and the two bounds above
|
|
35
|
+
# keep the behaviour this file was shipped with — the module load is best-
|
|
36
|
+
# effort on purpose.
|
|
37
|
+
zmodload zsh/datetime 2>/dev/null
|
|
38
|
+
# A fiftieth of a second, in microseconds. The fastest coalesced burst of
|
|
39
|
+
# ordinary typing is a read every few tens of milliseconds (a hand, not a
|
|
40
|
+
# terminal, is the thing being told apart), and the harness's own quiet
|
|
41
|
+
# window is built on the same arithmetic.
|
|
42
|
+
typeset -gi _TAI_PASTE_CHUNK_US=20000
|
|
43
|
+
# How many edit events at terminal speed make a paste. Five insertions in a
|
|
44
|
+
# row, each within a fiftieth of a second of the last, is 250 characters a
|
|
45
|
+
# second — no hand does that; keyboard auto-repeat at its fastest stock rate
|
|
46
|
+
# (about 30 a second) does not either, and a rate set past 50 a second only
|
|
47
|
+
# holds the quiet while the key is held, which costs one stale hint.
|
|
48
|
+
typeset -gi _TAI_PASTE_MIN_RUN=5
|
|
49
|
+
typeset -g _TAI_PASTE_TICK=""
|
|
50
|
+
typeset -gi _TAI_PASTE_RUN=0
|
|
51
|
+
typeset -gi _TAI_PASTE_NEW=0
|
|
52
|
+
typeset -gi _TAI_PASTE_FAST=0
|
|
53
|
+
typeset -gi _TAI_Q_OWED=0
|
|
54
|
+
|
|
32
55
|
# Is the line *typed*? A history line is not: Up fills the buffer with a
|
|
33
56
|
# command the user ran once, and on that buffer Down means "the next history
|
|
34
57
|
# entry" — it did not mean "open the list" until something was typed. The flag
|
|
@@ -39,18 +62,71 @@ typeset -gi _TAI_PASTE_JUMP=25
|
|
|
39
62
|
# a line nobody typed, with the line itself as its only row.
|
|
40
63
|
typeset -gi _TAI_TYPED=0
|
|
41
64
|
|
|
65
|
+
# $EPOCHREALTIME as whole microseconds in REPLY, and false where there is no
|
|
66
|
+
# clock. String surgery, not arithmetic on the float: the fraction is padded
|
|
67
|
+
# and cut to six digits by hand, and `10#` keeps a fraction that opens in a
|
|
68
|
+
# zero from being read as octal. This build's fraction runs longer than six
|
|
69
|
+
# digits, which is why the cut is there at all.
|
|
70
|
+
_tai_now_us() {
|
|
71
|
+
REPLY=0
|
|
72
|
+
[[ -n "$EPOCHREALTIME" ]] || return 1
|
|
73
|
+
local fr="${EPOCHREALTIME#*.}"
|
|
74
|
+
REPLY=$(( ${EPOCHREALTIME%%.*} * 1000000 + 10#${(r:6::0:)fr} ))
|
|
75
|
+
}
|
|
76
|
+
|
|
42
77
|
_tai_paste_state() {
|
|
43
78
|
# The unwrapped case: how far the buffer moved since the last redraw. One
|
|
44
79
|
# character in either direction is a keystroke — typed or removed — and ends
|
|
45
80
|
# the quiet; more than _TAI_PASTE_JUMP at once is an arrival and starts it.
|
|
46
|
-
# Everything between leaves the flag as it is
|
|
81
|
+
# Everything between leaves the flag as it is — unless the clock says it is
|
|
82
|
+
# a chunk of a paste, which is the branch below.
|
|
83
|
+
_TAI_PASTE_NEW=0
|
|
47
84
|
local -i d=$(( ${#BUFFER} - ${#_TAI_PASTE_PREV} ))
|
|
48
85
|
(( d < 0 )) && d=$(( -d ))
|
|
86
|
+
local -i now_us=0
|
|
87
|
+
_tai_now_us && now_us=$REPLY
|
|
88
|
+
# Terminal speed, judged against the previous redraw: an insertion within a
|
|
89
|
+
# fiftieth of a second of the last one is not a hand. The hook fires per
|
|
90
|
+
# widget, so a three-character chunk is three calls — two of them one
|
|
91
|
+
# character apart, microseconds together — and the last character of every
|
|
92
|
+
# chunk looks exactly like a keystroke. Without the clock, the tail of a
|
|
93
|
+
# paste would end the quiet that the middle of the same paste raised.
|
|
94
|
+
local -i fast=0
|
|
95
|
+
(( now_us && ${#_TAI_PASTE_TICK} )) && \
|
|
96
|
+
(( now_us - _TAI_PASTE_TICK < _TAI_PASTE_CHUNK_US )) && fast=1
|
|
97
|
+
_TAI_PASTE_FAST=$fast
|
|
98
|
+
# The run: consecutive edit events at terminal speed. The hook fires per
|
|
99
|
+
# widget, so a pasted character and a typed character both arrive as d==1 —
|
|
100
|
+
# the buffer cannot tell them apart, and neither bound above sees anything
|
|
101
|
+
# but one keystroke at a time. Only the clock separates a hand from a
|
|
102
|
+
# terminal, and only a run of them separates a paste from one lucky
|
|
103
|
+
# keystroke: two fast keys in a row happens to hands (rollover), twenty in
|
|
104
|
+
# a row does not.
|
|
105
|
+
if (( d != 0 )) && (( fast )); then
|
|
106
|
+
_TAI_PASTE_RUN+=1
|
|
107
|
+
else
|
|
108
|
+
_TAI_PASTE_RUN=0
|
|
109
|
+
fi
|
|
49
110
|
if (( d == 1 )); then
|
|
50
|
-
_TAI_PASTE_QUIET=0
|
|
111
|
+
(( fast )) || _TAI_PASTE_QUIET=0
|
|
51
112
|
elif (( d > _TAI_PASTE_JUMP )); then
|
|
113
|
+
(( _TAI_PASTE_QUIET )) || { _TAI_PASTE_QUIET=1; _TAI_PASTE_NEW=1; }
|
|
114
|
+
elif (( d > 1 )); then
|
|
115
|
+
# A coalesced insertion — several characters in one read — is one event
|
|
116
|
+
# that moved more than a keystroke and less than an arrival. At human
|
|
117
|
+
# speed it is a typing burst like any other; at terminal speed it is a
|
|
118
|
+
# chunk of a paste, and the run below will agree with it.
|
|
119
|
+
(( fast && ! _TAI_PASTE_QUIET )) && { _TAI_PASTE_QUIET=1; _TAI_PASTE_NEW=1; }
|
|
120
|
+
fi
|
|
121
|
+
# Enough of a run is a paste, whoever delivered it. Raised once — the
|
|
122
|
+
# crossing is the arrival, and everything after it rides the same quiet —
|
|
123
|
+
# and ended by the next edit at human speed, which is the keystroke rule
|
|
124
|
+
# above doing what it always did.
|
|
125
|
+
if (( _TAI_PASTE_RUN >= _TAI_PASTE_MIN_RUN && ! _TAI_PASTE_QUIET )); then
|
|
52
126
|
_TAI_PASTE_QUIET=1
|
|
127
|
+
_TAI_PASTE_NEW=1
|
|
53
128
|
fi
|
|
129
|
+
(( now_us )) && _TAI_PASTE_TICK=$now_us
|
|
54
130
|
_TAI_PASTE_PREV="$BUFFER"
|
|
55
131
|
}
|
|
56
132
|
|
|
@@ -158,7 +234,27 @@ _tai_update() {
|
|
|
158
234
|
(( ours_was )) && POSTDISPLAY=""
|
|
159
235
|
fi
|
|
160
236
|
_tai_paste_state
|
|
161
|
-
|
|
237
|
+
# A paste is not a query — all of it, not only its menu. The redraw that
|
|
238
|
+
# raised the quiet answers nothing: the line is still arriving, and a full
|
|
239
|
+
# query on a real index for a line nobody has read is the cost the chunk
|
|
240
|
+
# rule exists to stop. What it skips it owes — one query, at the first
|
|
241
|
+
# redraw that is not itself arriving at terminal speed, the keystroke that
|
|
242
|
+
# ends the paste or any edit after it — by which time the buffer is the
|
|
243
|
+
# line the user is reading, so the answer is the one they would have asked
|
|
244
|
+
# for. The hook fires per widget, so the middle of a paste owes and the
|
|
245
|
+
# tail of it is still fast: paying at any fast redraw would put the storm
|
|
246
|
+
# back one chunk at a time. The envelope keeps its single query: quiet
|
|
247
|
+
# raised by the paste widget is not an arrival this hook raised, and one
|
|
248
|
+
# query for a line that arrived at once is the deal the bracketed-paste
|
|
249
|
+
# tests have always held.
|
|
250
|
+
if (( _TAI_PASTE_NEW )); then
|
|
251
|
+
_TAI_Q_OWED=1
|
|
252
|
+
elif (( _TAI_Q_OWED )) && (( ! _TAI_PASTE_FAST )); then
|
|
253
|
+
_TAI_Q_OWED=0
|
|
254
|
+
_tai_query "$BUFFER" "$_TAI_LAST"
|
|
255
|
+
elif (( ! _TAI_Q_OWED )); then
|
|
256
|
+
_tai_query "$BUFFER" "$_TAI_LAST"
|
|
257
|
+
fi
|
|
162
258
|
# The extends test folds case — the lookup that filled _TAI_BEST does, and
|
|
163
259
|
# `ls down` reaching `ls Downloads` is the point of it — and the strip is
|
|
164
260
|
# positional: the head of the best line that matched is the buffer's own
|
|
@@ -589,9 +685,14 @@ _tai_line_init() {
|
|
|
589
685
|
_TAI_DREW=0
|
|
590
686
|
_TAI_MENU_DISMISS=""
|
|
591
687
|
# A new line is a new story: no paste has arrived on it yet, and nothing
|
|
592
|
-
# on it is typed.
|
|
688
|
+
# on it is typed. An owed query belongs to the line that skipped it — a
|
|
689
|
+
# debt carried past the prompt would be paid against a line nobody pasted.
|
|
593
690
|
_TAI_PASTE_PREV=""
|
|
594
691
|
_TAI_PASTE_QUIET=0
|
|
692
|
+
_TAI_PASTE_TICK=""
|
|
693
|
+
_TAI_PASTE_NEW=0
|
|
694
|
+
_TAI_PASTE_FAST=0
|
|
695
|
+
_TAI_Q_OWED=0
|
|
595
696
|
_TAI_TYPED=0
|
|
596
697
|
# And no directory snapshot is worth keeping either: the file answer reads
|
|
597
698
|
# its snapshots for a second after taking them, and a line that starts now
|
package/tai/cli.py
CHANGED
|
@@ -381,14 +381,19 @@ def _auto_maintain(total: int, newest_ts: int = 0) -> None:
|
|
|
381
381
|
def _rebuild_when_stale(newest_ts: int | None = None) -> None:
|
|
382
382
|
import time
|
|
383
383
|
|
|
384
|
-
from tai.
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
384
|
+
from tai.paths import index_location
|
|
385
|
+
|
|
386
|
+
# The index is named as a plain string and stat'd with os: importing
|
|
387
|
+
# tai.index to ask `Path.exists` pulled the engine, the knowledge loader
|
|
388
|
+
# and pathlib into every record and flush for one stat that usually says
|
|
389
|
+
# "fresh enough". The rebuild imports are inside the two branches that
|
|
390
|
+
# rebuild, so a debounce hit is one stat and one comparison.
|
|
391
|
+
idx = index_location()
|
|
392
|
+
if not os.path.exists(idx):
|
|
393
|
+
from tai.maintenance import _rebuild_quietly
|
|
389
394
|
_rebuild_quietly()
|
|
390
395
|
return
|
|
391
|
-
mtime =
|
|
396
|
+
mtime = os.stat(idx).st_mtime
|
|
392
397
|
if time.time() - mtime < _REBUILD_MIN_INTERVAL:
|
|
393
398
|
return
|
|
394
399
|
# The newest row's timestamp arrives from the same connection that did the
|
|
@@ -410,6 +415,7 @@ def _rebuild_when_stale(newest_ts: int | None = None) -> None:
|
|
|
410
415
|
return
|
|
411
416
|
newest = row[0] if row and row[0] else 0
|
|
412
417
|
if newest and newest > mtime:
|
|
418
|
+
from tai.maintenance import _rebuild_quietly
|
|
413
419
|
_rebuild_quietly()
|
|
414
420
|
|
|
415
421
|
|
package/tai/engcache.py
CHANGED
|
@@ -28,7 +28,11 @@ from __future__ import annotations
|
|
|
28
28
|
|
|
29
29
|
import marshal
|
|
30
30
|
import os
|
|
31
|
-
|
|
31
|
+
|
|
32
|
+
# `pathlib` is deliberately not imported at module level: the one-shot suggest
|
|
33
|
+
# loads this module on its warm path, and a warm cache reads no `Path` — only
|
|
34
|
+
# `_cache_path`'s Path-in-Path-out contract for tests and tools that pass one
|
|
35
|
+
# needs the type, and that import happens on the first call that wants it.
|
|
32
36
|
|
|
33
37
|
# Bump when the cached shape changes (new fields on CmdStats, new maps, a new
|
|
34
38
|
# serialization). 2: CmdStats gained `nf`. 3: pickle gave way to marshal, and
|
|
@@ -41,26 +45,46 @@ VERSION = 4
|
|
|
41
45
|
_MAGIC = "tai-engine-m"
|
|
42
46
|
|
|
43
47
|
|
|
44
|
-
def _cache_path(db
|
|
48
|
+
def _cache_path(db) -> "Path | str":
|
|
45
49
|
# Beside the database, named for it: a scratch TAI_DB in a test cannot
|
|
46
50
|
# stamp a cache over the developer's real one, and `tai uninstall` (which
|
|
47
51
|
# removes the whole data directory) removes the cache with everything else.
|
|
52
|
+
# A `Path` in, a `Path` out; a string in, a string out — the one-shot path
|
|
53
|
+
# passes the string form so a warm process imports no pathlib at all.
|
|
48
54
|
import hashlib
|
|
49
55
|
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
dsp = os.fspath(db)
|
|
57
|
+
name = hashlib.sha1(dsp.encode("utf-8", "replace")).hexdigest()[:16]
|
|
58
|
+
out = os.path.join(os.path.dirname(dsp), f"engine-{name}.cache")
|
|
59
|
+
if type(db) is str:
|
|
60
|
+
return out
|
|
61
|
+
from pathlib import Path
|
|
62
|
+
return Path(out)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _sibling(cp, name: str):
|
|
66
|
+
"""`name` in the directory `cp` lives in, in `cp`'s own kind."""
|
|
67
|
+
out = os.path.join(os.path.dirname(os.fspath(cp)), name)
|
|
68
|
+
if type(cp) is str:
|
|
69
|
+
return out
|
|
70
|
+
from pathlib import Path
|
|
71
|
+
return Path(out)
|
|
52
72
|
|
|
53
73
|
|
|
54
|
-
def get(db_path
|
|
74
|
+
def get(db_path, build) -> object:
|
|
55
75
|
"""The built engine for `db_path`, loading a cached one when it is current.
|
|
56
76
|
|
|
57
|
-
`
|
|
58
|
-
|
|
77
|
+
`db_path` may be a `Path` or a plain string — the one-shot path passes the
|
|
78
|
+
string form (`tai.paths.db_location`) so a warm process never imports
|
|
79
|
+
pathlib. `build` is called with no arguments and must return the engine;
|
|
80
|
+
it runs at most once per process, and only when the cache is missing or
|
|
81
|
+
stale.
|
|
59
82
|
"""
|
|
60
83
|
if os.environ.get("TAI_NO_ENGINE_CACHE") == "1":
|
|
61
84
|
return build()
|
|
85
|
+
dsp = os.fspath(db_path)
|
|
62
86
|
try:
|
|
63
|
-
st =
|
|
87
|
+
st = os.stat(dsp)
|
|
64
88
|
except OSError:
|
|
65
89
|
return build() # no store yet: nothing to cache against
|
|
66
90
|
key = (st.st_size, st.st_mtime_ns)
|
|
@@ -76,11 +100,12 @@ def get(db_path: Path, build) -> object:
|
|
|
76
100
|
pass # torn, stale, or foreign: build instead
|
|
77
101
|
eng = build()
|
|
78
102
|
try:
|
|
79
|
-
tmp = cp
|
|
103
|
+
tmp = _sibling(cp, (cp.name if type(cp) is not str
|
|
104
|
+
else os.path.basename(cp)) + f".tmp{os.getpid()}")
|
|
80
105
|
with open(tmp, "wb") as f:
|
|
81
106
|
f.write(f"{_MAGIC} {VERSION} {key[0]} {key[1]}\n".encode("ascii"))
|
|
82
107
|
marshal.dump(_snapshot(eng), f, 4)
|
|
83
|
-
os.replace(tmp, cp)
|
|
108
|
+
os.replace(os.fspath(tmp), os.fspath(cp))
|
|
84
109
|
except Exception:
|
|
85
110
|
pass # a read-only or vanished directory is fine
|
|
86
111
|
return eng
|
package/tai/engine.py
CHANGED
|
@@ -38,6 +38,14 @@ _LOG1P20 = math.log1p(20)
|
|
|
38
38
|
# Half-life of a command's recency, in days, as `exp(-age / TAU)`.
|
|
39
39
|
_TAU = 14.0
|
|
40
40
|
|
|
41
|
+
# The store's longest-learned-command bound, imported from tai.store on first
|
|
42
|
+
# use and kept here. `add` used to run `from tai.store import MAX_CMD_LEN`
|
|
43
|
+
# on *every* call — a sys.modules lookup and attribute fetch each time, 20k
|
|
44
|
+
# of them in one build_from_rows — because a module-level import would have
|
|
45
|
+
# dragged sqlite3 and re into every process that loads the engine without
|
|
46
|
+
# ever learning. The lazy global keeps the lazy import and pays it once.
|
|
47
|
+
_MAX_CMD_LEN: int | None = None
|
|
48
|
+
|
|
41
49
|
|
|
42
50
|
def hour_bucket(ts: int | None = None) -> int:
|
|
43
51
|
try:
|
|
@@ -157,8 +165,11 @@ class Engine:
|
|
|
157
165
|
return
|
|
158
166
|
# The same bound is_recordable applies — imported here, where a row this
|
|
159
167
|
# long is rare, rather than at module load, which every fast process pays.
|
|
160
|
-
|
|
161
|
-
if
|
|
168
|
+
global _MAX_CMD_LEN
|
|
169
|
+
if _MAX_CMD_LEN is None:
|
|
170
|
+
from tai.store import MAX_CMD_LEN as _mcl
|
|
171
|
+
_MAX_CMD_LEN = _mcl
|
|
172
|
+
if len(cmd) > _MAX_CMD_LEN:
|
|
162
173
|
return
|
|
163
174
|
ts = ts if ts is not None else int(time.time())
|
|
164
175
|
st = self.cmds.get(cmd)
|
|
@@ -266,10 +277,15 @@ class Engine:
|
|
|
266
277
|
on_path=None) -> dict:
|
|
267
278
|
"""Rank candidates for `prefix`.
|
|
268
279
|
|
|
269
|
-
`exclude` drops candidates before ranking.
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
280
|
+
`exclude` drops candidates before ranking. It is read with `in` — a
|
|
281
|
+
set of names, or any per-name verdict holder such as the predictor's
|
|
282
|
+
`LazyStale`, which answers a candidate's path-liveness question the
|
|
283
|
+
first time it is consulted and remembers it — so a caller with an
|
|
284
|
+
expensive exclusion question pays it only for the names this query
|
|
285
|
+
actually walks. The engine itself never touches the filesystem — it
|
|
286
|
+
has to stay a sub-millisecond in-memory ranker — so the caller decides
|
|
287
|
+
policy (currently: commands whose paths no longer exist) and passes
|
|
288
|
+
the answer in.
|
|
273
289
|
|
|
274
290
|
`on_path` is the caller's answer to one question — is this command's
|
|
275
291
|
first word installed? — asked at most once per candidate, and only for
|
package/tai/fresh.py
CHANGED
|
@@ -34,9 +34,12 @@ below and by `TAI_FILE_ROOTS`, which adds roots to both.
|
|
|
34
34
|
"""
|
|
35
35
|
from __future__ import annotations
|
|
36
36
|
|
|
37
|
-
import glob
|
|
38
37
|
import os
|
|
39
38
|
|
|
39
|
+
# `glob` is deliberately not imported at module level: it drags fnmatch and re
|
|
40
|
+
# (about 3ms) into every one-shot process, and only a query whose answer comes
|
|
41
|
+
# from the disk ever globs. The functions below import it on first use.
|
|
42
|
+
|
|
40
43
|
# How many files one root may contribute, and how many roots are read in all.
|
|
41
44
|
# Both are shortlists, not truncation: the way to see more is to type more of
|
|
42
45
|
# the word, which narrows every root at once. A downloads folder has a season of
|
|
@@ -159,7 +162,9 @@ def _scan(pattern: str, cut: int, written: str) -> list[tuple[float, str]]:
|
|
|
159
162
|
built from mtimes, so the cap is applied after the sort.
|
|
160
163
|
"""
|
|
161
164
|
try:
|
|
162
|
-
|
|
165
|
+
from glob import glob as _glob
|
|
166
|
+
|
|
167
|
+
names = _glob(pattern)
|
|
163
168
|
except (OSError, ValueError):
|
|
164
169
|
return []
|
|
165
170
|
out: list[tuple[float, str]] = []
|
package/tai/index.py
CHANGED
|
@@ -140,7 +140,13 @@ def bash_index_path() -> Path:
|
|
|
140
140
|
|
|
141
141
|
|
|
142
142
|
def _zq(value: str) -> str:
|
|
143
|
-
|
|
143
|
+
# The apostrophe is the rare case, and the scan for it is a C-speed `in`;
|
|
144
|
+
# quoting the common case was one str.replace call per index entry — 38k
|
|
145
|
+
# of them a rebuild — for a replace that never had anything to replace.
|
|
146
|
+
value = str(value)
|
|
147
|
+
if "'" not in value:
|
|
148
|
+
return f"'{value}'"
|
|
149
|
+
return "'" + value.replace("'", "'\\''") + "'"
|
|
144
150
|
|
|
145
151
|
|
|
146
152
|
def _word_value(values: list[str]) -> str:
|
package/tai/maintenance.py
CHANGED
|
@@ -7,10 +7,14 @@ eventually reclaims it — a lock that outlives its process would stop every
|
|
|
7
7
|
future rebuild, silently, forever.
|
|
8
8
|
"""
|
|
9
9
|
import os
|
|
10
|
-
import shutil
|
|
11
10
|
import time
|
|
12
11
|
from pathlib import Path
|
|
13
12
|
|
|
13
|
+
# `shutil` is imported by the two functions that remove things, not at module
|
|
14
|
+
# level: this module loads on the record path's freshness check, and shutil
|
|
15
|
+
# dragged ~1.3ms of bz2/lzma/fnmatch with it for a question that usually ends
|
|
16
|
+
# in one stat.
|
|
17
|
+
|
|
14
18
|
|
|
15
19
|
def _lock_path() -> Path:
|
|
16
20
|
from tai.store import db_path
|
|
@@ -63,6 +67,8 @@ def _owner_gone(lock: Path) -> bool:
|
|
|
63
67
|
running = True # no usable pid: assume a live holder
|
|
64
68
|
if running:
|
|
65
69
|
return False # this boot, this minute, and still running
|
|
70
|
+
import shutil
|
|
71
|
+
|
|
66
72
|
shutil.rmtree(lock, ignore_errors=True)
|
|
67
73
|
return True
|
|
68
74
|
|
|
@@ -127,6 +133,8 @@ def _rebuild_index():
|
|
|
127
133
|
the ones that do not go through _rebuild_quietly.
|
|
128
134
|
"""
|
|
129
135
|
from tai.index import build
|
|
136
|
+
import shutil
|
|
137
|
+
|
|
130
138
|
lock = _lock_path()
|
|
131
139
|
if not _take_lock(lock):
|
|
132
140
|
return None
|
package/tai/paths.py
CHANGED
|
@@ -30,7 +30,15 @@ from __future__ import annotations
|
|
|
30
30
|
|
|
31
31
|
import os
|
|
32
32
|
from functools import lru_cache
|
|
33
|
-
|
|
33
|
+
|
|
34
|
+
# `pathlib` is deliberately NOT imported at module level. This module sits under
|
|
35
|
+
# everything: the engine (which the keystroke-free one-shot path loads), the
|
|
36
|
+
# store, and the spool, so a module-level import made every one of those
|
|
37
|
+
# processes pay pathlib's own ~7ms — fnmatch, re and urllib.parse with it — for
|
|
38
|
+
# a type only two builders of file names ever need. The functions here that
|
|
39
|
+
# hand back a `Path` import it on first call instead; every other reader of
|
|
40
|
+
# this module, and the warm engine-cache suggest path above all, imports
|
|
41
|
+
# nothing heavier than `os`.
|
|
34
42
|
|
|
35
43
|
# Verdicts. Only MISSING removes a candidate.
|
|
36
44
|
EXISTS = "exists"
|
|
@@ -149,8 +157,28 @@ _UNANALYSABLE = set('$`*?[]{}<>|;&()!#\\\n\t"\'')
|
|
|
149
157
|
|
|
150
158
|
# Cached (token, cwd) verdicts. Builds and purges ask about the same paths
|
|
151
159
|
# thousands of times, and the filesystem does not change under one run.
|
|
160
|
+
# Absolute and `~` tokens are cached under the empty cwd: they are true or
|
|
161
|
+
# false in every directory, so one answer serves them all.
|
|
152
162
|
_cache: dict[tuple[str, str], bool] = {}
|
|
153
163
|
|
|
164
|
+
# Directories already judged with `os.path.isdir`, per process. `_resolve` used
|
|
165
|
+
# to re-ask it for every token of every command against every recorded cwd —
|
|
166
|
+
# a dozen stats per command on a build, and the same few directories every
|
|
167
|
+
# time. The filesystem does not change under one run; that is the same promise
|
|
168
|
+
# `_cache` above is kept under.
|
|
169
|
+
_isdir: set[str] = set()
|
|
170
|
+
_notdir: set[str] = set()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _is_dir(path: str) -> bool:
|
|
174
|
+
if path in _isdir:
|
|
175
|
+
return True
|
|
176
|
+
if path in _notdir:
|
|
177
|
+
return False
|
|
178
|
+
hit = os.path.isdir(path)
|
|
179
|
+
(_isdir if hit else _notdir).add(path)
|
|
180
|
+
return hit
|
|
181
|
+
|
|
154
182
|
# Every account name on this machine, or None where they cannot be read. Read
|
|
155
183
|
# once and only when a `~name` token actually turns up, because most machines
|
|
156
184
|
# have no such command in their history at all and this is a question about the
|
|
@@ -184,10 +212,38 @@ def data_dir() -> Path:
|
|
|
184
212
|
~/.local/share would split the install across two directories and leave
|
|
185
213
|
`tai uninstall` deleting one half of it and reporting complete.
|
|
186
214
|
"""
|
|
215
|
+
from pathlib import Path
|
|
216
|
+
|
|
187
217
|
base = os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share"
|
|
188
218
|
return Path(base) / "tai"
|
|
189
219
|
|
|
190
220
|
|
|
221
|
+
def data_location() -> str:
|
|
222
|
+
"""`data_dir()` as a plain string, with pathlib still unpaid.
|
|
223
|
+
|
|
224
|
+
The string form of the same decision, for the processes that join file
|
|
225
|
+
names onto it and never want a `Path`: `os.path.expanduser("~")` is the
|
|
226
|
+
same home `Path.home()` reads, and the join is the same join.
|
|
227
|
+
"""
|
|
228
|
+
base = os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share")
|
|
229
|
+
return os.path.join(base, "tai")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def db_location() -> str:
|
|
233
|
+
"""`db_path()` as a plain string, with pathlib still unpaid.
|
|
234
|
+
|
|
235
|
+
The engine cache is keyed on the database file's own (size, mtime_ns), so
|
|
236
|
+
the one-shot suggest asks for this path before it can ask whether it needs
|
|
237
|
+
the store at all — and a warm cache reads neither sqlite3, re, nor
|
|
238
|
+
pathlib. Every caller that wants the type wraps this in `Path` via
|
|
239
|
+
`db_path()`; nothing else about the decision changes.
|
|
240
|
+
"""
|
|
241
|
+
env = os.environ.get("TAI_DB")
|
|
242
|
+
if env:
|
|
243
|
+
return env
|
|
244
|
+
return os.path.join(data_location(), "history.db")
|
|
245
|
+
|
|
246
|
+
|
|
191
247
|
def db_path() -> Path:
|
|
192
248
|
"""Where the history store lives — `TAI_DB`, or beside `data_dir()`.
|
|
193
249
|
|
|
@@ -201,7 +257,23 @@ def db_path() -> Path:
|
|
|
201
257
|
re-export, because the writer and every reader of the store still say
|
|
202
258
|
`store.db_path`.
|
|
203
259
|
"""
|
|
204
|
-
|
|
260
|
+
from pathlib import Path
|
|
261
|
+
|
|
262
|
+
return Path(db_location())
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def index_location() -> str:
|
|
266
|
+
"""The zsh index as a plain string — `TAI_INDEX`, or beside the data.
|
|
267
|
+
|
|
268
|
+
The string twin of `tai.index.index_path`, for the readers that only stat
|
|
269
|
+
the file: the record path's rebuild debounce asks "is the index older
|
|
270
|
+
than the newest row" once per recorded batch, and importing tai.index for
|
|
271
|
+
that stat pulled the engine, the knowledge loader and pathlib into every
|
|
272
|
+
flush. The env var is read identically, so the two names stay one
|
|
273
|
+
decision.
|
|
274
|
+
"""
|
|
275
|
+
return os.environ.get("TAI_INDEX") or os.path.join(
|
|
276
|
+
data_location(), "zsh-index.zsh")
|
|
205
277
|
|
|
206
278
|
|
|
207
279
|
def _unquote(token: str) -> str:
|
|
@@ -210,8 +282,18 @@ def _unquote(token: str) -> str:
|
|
|
210
282
|
return token
|
|
211
283
|
|
|
212
284
|
|
|
285
|
+
@lru_cache(maxsize=8192)
|
|
213
286
|
def is_path_like(token: str) -> bool:
|
|
214
|
-
"""True when `token` can only be read as a filesystem path.
|
|
287
|
+
"""True when `token` can only be read as a filesystem path.
|
|
288
|
+
|
|
289
|
+
Memoised: it is a pure function of the token, and the liveness walks ask it
|
|
290
|
+
for every token of every distinct command — the same few thousand strings
|
|
291
|
+
over and over, since a history repeats its paths. Measured on a 20k-row
|
|
292
|
+
history, the per-character scan was 58% of a warm `tai suggest`'s process
|
|
293
|
+
and a quarter of `tai refresh`; the cache turns both into one C-speed dict
|
|
294
|
+
hit per distinct token. Bounded, so a long-running dashboard cannot grow
|
|
295
|
+
it without limit.
|
|
296
|
+
"""
|
|
215
297
|
if not token or token.startswith("-") or "=" in token:
|
|
216
298
|
return False
|
|
217
299
|
if any(ch in _UNANALYSABLE for ch in token):
|
|
@@ -319,10 +401,14 @@ def _resolve(token: str, cwds) -> str:
|
|
|
319
401
|
return UNKNOWN
|
|
320
402
|
return EXISTS if os.path.exists(os.path.expanduser(token)) else MISSING
|
|
321
403
|
if os.path.isabs(token):
|
|
322
|
-
|
|
404
|
+
key = (token, "")
|
|
405
|
+
hit = _cache.get(key)
|
|
406
|
+
if hit is None:
|
|
407
|
+
hit = _cache[key] = os.path.exists(token)
|
|
408
|
+
return EXISTS if hit else MISSING
|
|
323
409
|
# A relative path is only knowable from a directory it was actually run in.
|
|
324
410
|
# With no such directory the honest answer is "no opinion".
|
|
325
|
-
known = [c for c in cwds if c and
|
|
411
|
+
known = [c for c in cwds if c and _is_dir(c)]
|
|
326
412
|
if not known:
|
|
327
413
|
return UNKNOWN
|
|
328
414
|
for cwd in known:
|
package/tai/predictor.py
CHANGED
|
@@ -14,10 +14,11 @@ import time
|
|
|
14
14
|
|
|
15
15
|
from tai.engine import WRAPPERS, Engine
|
|
16
16
|
from tai.fresh import files
|
|
17
|
-
from tai.paths import alive_in,
|
|
17
|
+
from tai.paths import (MISSING, alive_in, command_verdict, db_location,
|
|
18
|
+
enabled, takes_file)
|
|
18
19
|
|
|
19
20
|
_E: Engine | None = None
|
|
20
|
-
_STALE: frozenset = frozenset()
|
|
21
|
+
_STALE: "LazyStale | frozenset" = frozenset()
|
|
21
22
|
|
|
22
23
|
# Is this command's first word installed? Asked once per word per process and
|
|
23
24
|
# kept here, because PATH does not change under a running process in any way
|
|
@@ -29,25 +30,126 @@ _STALE: frozenset = frozenset()
|
|
|
29
30
|
_WHICH: dict[str, bool] = {}
|
|
30
31
|
|
|
31
32
|
|
|
33
|
+
def _path_lookup(word: str) -> bool:
|
|
34
|
+
"""`shutil.which(word) is not None`, answered without importing shutil.
|
|
35
|
+
|
|
36
|
+
shutil was 5ms of a warm one-shot suggest — and it dragged re, fnmatch,
|
|
37
|
+
bz2 and lzma in behind it — for a PATH scan this module can do with os
|
|
38
|
+
alone. The walk is shutil.which's own, POSIX branch, line for line: a
|
|
39
|
+
word with a directory part is looked up directly; an empty PATH element
|
|
40
|
+
means the current directory; a hit must exist, be executable, and not be
|
|
41
|
+
a directory. Only the Windows PATHEXT machinery is absent — the plugin
|
|
42
|
+
and the installer are Linux-only anyway.
|
|
43
|
+
"""
|
|
44
|
+
dirname, name = os.path.split(word)
|
|
45
|
+
if dirname:
|
|
46
|
+
dirs = [dirname]
|
|
47
|
+
else:
|
|
48
|
+
path = os.environ.get("PATH")
|
|
49
|
+
if path is None:
|
|
50
|
+
try:
|
|
51
|
+
path = os.confstr("CS_PATH")
|
|
52
|
+
except (AttributeError, ValueError):
|
|
53
|
+
path = os.defpath
|
|
54
|
+
if not path:
|
|
55
|
+
return False
|
|
56
|
+
dirs = os.fsdecode(path).split(os.pathsep)
|
|
57
|
+
seen: set[str] = set()
|
|
58
|
+
for d in dirs:
|
|
59
|
+
norm = os.path.normcase(d)
|
|
60
|
+
if norm in seen:
|
|
61
|
+
continue
|
|
62
|
+
seen.add(norm)
|
|
63
|
+
cand = os.path.join(d, name)
|
|
64
|
+
if (os.path.exists(cand) and os.access(cand, os.F_OK | os.X_OK)
|
|
65
|
+
and not os.path.isdir(cand)):
|
|
66
|
+
return True
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
32
70
|
def _on_path(word: str) -> bool:
|
|
33
71
|
"""Whether `word` resolves to an executable on this machine's PATH."""
|
|
34
|
-
import shutil
|
|
35
72
|
hit = _WHICH.get(word)
|
|
36
73
|
if hit is None:
|
|
37
|
-
hit = _WHICH[word] =
|
|
74
|
+
hit = _WHICH[word] = _path_lookup(word)
|
|
38
75
|
return hit
|
|
39
76
|
|
|
40
77
|
|
|
41
|
-
|
|
42
|
-
"""
|
|
78
|
+
class LazyStale:
|
|
79
|
+
"""The engine's stale set, asked lazily and remembered per command.
|
|
80
|
+
|
|
81
|
+
`paths.stale_in` walks every distinct command's every token up front, and
|
|
82
|
+
the one-shot suggest paid that whole walk — measured at more than half of
|
|
83
|
+
its process on a 20k-row history — before ranking a handful of candidates
|
|
84
|
+
for one prefix. The verdict for one command is a pure function of the
|
|
85
|
+
command, the directories it was recorded in, and the directory the answer
|
|
86
|
+
is shown in, so it can be asked per candidate instead: the engine consults
|
|
87
|
+
`exclude` for exactly the names it would have filtered, and only those
|
|
88
|
+
verdicts are ever computed. For `tai suggest git ` that is the ~300
|
|
89
|
+
candidates behind the prefix instead of every command in the store; for an
|
|
90
|
+
empty prefix it is the same walk, deferred.
|
|
91
|
+
|
|
92
|
+
The full walk is still available — `materialize`, for the one reader that
|
|
93
|
+
counts the set, the dashboard's state panel. Behaves like a set for every
|
|
94
|
+
question the engine and the web page ask: `in`, `len`, truthiness.
|
|
95
|
+
|
|
96
|
+
The evidence rule is `stale_commands`' own, verbatim: a command is stale
|
|
97
|
+
when any of its path-like tokens is MISSING, judged from the directories
|
|
98
|
+
it was recorded in plus the one fallback directory (the cwd the answer is
|
|
99
|
+
shown in, appended only when the records do not already have it), and
|
|
100
|
+
`TAI_SKIP_PATH_CHECK=1` turns the whole question off.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
__slots__ = ("_eng", "_cwd", "_memo")
|
|
104
|
+
|
|
105
|
+
def __init__(self, eng: Engine, cwd: str = ""):
|
|
106
|
+
self._eng = eng
|
|
107
|
+
self._cwd = cwd or ""
|
|
108
|
+
self._memo: dict[str, bool] = {}
|
|
109
|
+
|
|
110
|
+
def _ask(self, cmd: str) -> bool:
|
|
111
|
+
hit = self._memo.get(cmd)
|
|
112
|
+
if hit is None:
|
|
113
|
+
if not enabled():
|
|
114
|
+
return False
|
|
115
|
+
st = self._eng.cmds.get(cmd)
|
|
116
|
+
cwds = list(st.cwd) if st is not None else []
|
|
117
|
+
if self._cwd and self._cwd not in cwds:
|
|
118
|
+
cwds.append(self._cwd)
|
|
119
|
+
hit = self._memo[cmd] = command_verdict(cmd, cwds) == MISSING
|
|
120
|
+
return hit
|
|
121
|
+
|
|
122
|
+
def __call__(self, cmd: str) -> bool:
|
|
123
|
+
return self._ask(cmd)
|
|
124
|
+
|
|
125
|
+
def __contains__(self, cmd: str) -> bool:
|
|
126
|
+
return self._ask(cmd)
|
|
127
|
+
|
|
128
|
+
def __bool__(self) -> bool:
|
|
129
|
+
# Truthy so the engine always consults the verdicts; an all-alive
|
|
130
|
+
# history filters to exactly what it filtered when the walk was eager.
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
def __len__(self) -> int:
|
|
134
|
+
self.materialize()
|
|
135
|
+
return sum(1 for v in self._memo.values() if v)
|
|
136
|
+
|
|
137
|
+
def materialize(self) -> "LazyStale":
|
|
138
|
+
"""Ask every command in the engine once, so `len` is the full count."""
|
|
139
|
+
for cmd in self._eng.cmds:
|
|
140
|
+
self._ask(cmd)
|
|
141
|
+
return self
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _stale_for(eng: Engine) -> LazyStale:
|
|
145
|
+
"""The stale question for this engine, asked lazily.
|
|
43
146
|
|
|
44
147
|
The one-shot suggestion is shown in the directory the suggest process runs
|
|
45
148
|
in, so the fallback cwd is passed explicitly here — and only here. The
|
|
46
149
|
index builder uses recorded evidence alone, because its snapshot is shown
|
|
47
150
|
in every directory, not just this one.
|
|
48
151
|
"""
|
|
49
|
-
|
|
50
|
-
return stale_in(eng, os.getcwd())
|
|
152
|
+
return LazyStale(eng, os.getcwd())
|
|
51
153
|
|
|
52
154
|
|
|
53
155
|
def _build_engine() -> Engine:
|
|
@@ -87,10 +189,12 @@ def suggest(prefix: str = "", cwd: str = "", repo: str = "",
|
|
|
87
189
|
if _E is None:
|
|
88
190
|
# A fresh process rebuilds the engine from SQLite unless a disk cache
|
|
89
191
|
# taken by an earlier process is still current — see tai/engcache.py.
|
|
90
|
-
# The path-liveness set is deliberately NOT cached: it
|
|
91
|
-
# the directory this process runs in, which changes
|
|
192
|
+
# The path-liveness set is deliberately NOT cached across processes: it
|
|
193
|
+
# answers against the directory this process runs in, which changes
|
|
194
|
+
# call to call. The store is named as a plain string so a warm cache
|
|
195
|
+
# imports no pathlib, no sqlite3 and no re — see tai.paths.db_location.
|
|
92
196
|
from tai.engcache import get
|
|
93
|
-
_E = get(
|
|
197
|
+
_E = get(db_location(), _build_engine)
|
|
94
198
|
_STALE = _stale_for(_E)
|
|
95
199
|
res = _E.suggest(prefix=prefix or "", cwd=cwd, repo=repo, branch=branch,
|
|
96
200
|
last_commands=last_commands or [], limit=limit, temp=temp,
|
|
@@ -138,7 +242,7 @@ def explain(prefix: str, cmd: str, cwd: str = "", repo: str = "",
|
|
|
138
242
|
global _E, _STALE
|
|
139
243
|
if _E is None:
|
|
140
244
|
from tai.engcache import get
|
|
141
|
-
_E = get(
|
|
245
|
+
_E = get(db_location(), _build_engine)
|
|
142
246
|
_STALE = _stale_for(_E)
|
|
143
247
|
out = _E.explain(prefix or "", cmd, cwd=cwd, repo=repo, branch=branch,
|
|
144
248
|
last_commands=last_commands or [], on_path=_on_path)
|
package/tai/spool.py
CHANGED
|
@@ -33,6 +33,8 @@ from __future__ import annotations
|
|
|
33
33
|
import os
|
|
34
34
|
import time
|
|
35
35
|
|
|
36
|
+
from tai.paths import data_location
|
|
37
|
+
|
|
36
38
|
RS = "\x1e"
|
|
37
39
|
US = "\x1f"
|
|
38
40
|
|
|
@@ -59,7 +61,9 @@ def spool_path() -> str:
|
|
|
59
61
|
The answer is a plain path string, not a `Path`: this module loads on the
|
|
60
62
|
record fallback and the flush, and importing pathlib costs about 9ms of
|
|
61
63
|
enum, re and urllib.parse — per process, for what is here only string
|
|
62
|
-
plumbing. The
|
|
64
|
+
plumbing. The default is joined with os.path from the same base
|
|
65
|
+
`tai.paths.data_location` reads, so the module imports nothing heavier
|
|
66
|
+
than `os`; the one caller that genuinely wants a `Path` wraps it itself.
|
|
63
67
|
"""
|
|
64
68
|
env = os.environ.get("TAI_SPOOL")
|
|
65
69
|
if env:
|
|
@@ -69,8 +73,7 @@ def spool_path() -> str:
|
|
|
69
73
|
if "/" in db:
|
|
70
74
|
return db.rsplit("/", 1)[0] + "/spool.log"
|
|
71
75
|
return "spool.log"
|
|
72
|
-
|
|
73
|
-
return os.path.join(str(data_dir()), "spool.log")
|
|
76
|
+
return os.path.join(data_location(), "spool.log")
|
|
74
77
|
|
|
75
78
|
|
|
76
79
|
def spool_append(cmd: str, cwd: str = "", exit_code: int = 0,
|
package/tai/store.py
CHANGED
|
@@ -6,10 +6,15 @@ import re
|
|
|
6
6
|
import sqlite3
|
|
7
7
|
import time
|
|
8
8
|
from contextlib import contextmanager
|
|
9
|
-
from pathlib import Path
|
|
10
9
|
|
|
11
10
|
from tai.paths import enabled as paths_enabled
|
|
12
11
|
|
|
12
|
+
# `pathlib` is not imported at module level on purpose: this module loads on
|
|
13
|
+
# the record and flush paths — once per batch the shells spool — and the type
|
|
14
|
+
# is needed only by the history-import walk (`_history_files`). A module-level
|
|
15
|
+
# import made every record pay pathlib's ~4ms and its urllib.parse/ipaddress
|
|
16
|
+
# chain for a code path that runs on `tai refresh`, not on recording.
|
|
17
|
+
|
|
13
18
|
# The longest command worth learning. A paste — a curl with its headers and
|
|
14
19
|
# cookies, a heredoc — is not vocabulary: it is never retyped from memory, and
|
|
15
20
|
# painted back as a hint it wraps the screen out of existence (the report that
|
|
@@ -20,7 +25,21 @@ from tai.paths import enabled as paths_enabled
|
|
|
20
25
|
MAX_CMD_LEN = 1000
|
|
21
26
|
|
|
22
27
|
# A command must be safe to persist and useful to suggest before it is kept.
|
|
23
|
-
|
|
28
|
+
# One reject scan, not two: is_recordable runs on every row the engine builds
|
|
29
|
+
# from and every frame the spool drains, and two `.search` calls per row were
|
|
30
|
+
# 20k regex dispatches a build for what one alternation answers. The two rules
|
|
31
|
+
# it joins, in order:
|
|
32
|
+
#
|
|
33
|
+
# * a secret-shaped word — `password`, `secret`, `token`, an AWS key id,
|
|
34
|
+
# a PEM header — matched case-insensitively, scoped inside `(?i: …)` so
|
|
35
|
+
# the rest of the pattern keeps its own exact letter-cases;
|
|
36
|
+
# * a session-persistence harness envelope, matched case-sensitively.
|
|
37
|
+
_REJECT = re.compile(
|
|
38
|
+
r"(?i:password|secret|token|AKIA|-----BEGIN)"
|
|
39
|
+
r"|__[A-Za-z0-9]+_(?:START|END)_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-"
|
|
40
|
+
r"[0-9a-f]{4}-[0-9a-f]{12}__"
|
|
41
|
+
r"|__[a-z0-9_]*status=\$\?"
|
|
42
|
+
)
|
|
24
43
|
|
|
25
44
|
# The raw-control-byte half of the filter, at C speed: this runs once per row
|
|
26
45
|
# the engine builds from, and the previous per-character `any(ord(ch) < 32 …)`
|
|
@@ -73,7 +92,7 @@ def is_recordable(cmd: str) -> bool:
|
|
|
73
92
|
# sequence's continuation bytes are all 0x80 and up.)
|
|
74
93
|
if _CTRL.search(cmd):
|
|
75
94
|
return False
|
|
76
|
-
return not
|
|
95
|
+
return not _REJECT.search(cmd)
|
|
77
96
|
|
|
78
97
|
|
|
79
98
|
def db_path() -> Path:
|
|
@@ -371,6 +390,8 @@ def count() -> tuple[int, int]:
|
|
|
371
390
|
|
|
372
391
|
def _history_files(home: Path) -> list[Path]:
|
|
373
392
|
"""Return supported history files, including a custom HISTFILE."""
|
|
393
|
+
from pathlib import Path
|
|
394
|
+
|
|
374
395
|
candidates: list[Path] = []
|
|
375
396
|
custom = os.environ.get("HISTFILE", "").strip()
|
|
376
397
|
if custom:
|
|
@@ -418,6 +439,8 @@ def _import_rows(con: sqlite3.Connection) -> int:
|
|
|
418
439
|
"""
|
|
419
440
|
imported = 0
|
|
420
441
|
now = int(time.time())
|
|
442
|
+
from pathlib import Path
|
|
443
|
+
|
|
421
444
|
home = Path.home()
|
|
422
445
|
for f in _history_files(home):
|
|
423
446
|
try:
|
package/tai/typo.py
CHANGED
|
@@ -254,12 +254,26 @@ def shadow_map(eng, lines=None) -> dict[str, str]:
|
|
|
254
254
|
for c, bag in counts.items():
|
|
255
255
|
for ch in low[c]:
|
|
256
256
|
bag[ch] = bag.get(ch, 0) + 1
|
|
257
|
+
# The evidence each line carries, read once: the tuple a target must beat.
|
|
258
|
+
ev_of = {c: (eng.cmds[c].success, eng.cmds[c].freq, eng.cmds[c].last_ts)
|
|
259
|
+
for c in names}
|
|
257
260
|
groups: dict[str, dict[int, list[str]]] = {}
|
|
258
261
|
for c in names:
|
|
259
262
|
groups.setdefault(low[c].split(" ", 1)[0], {}).setdefault(len(c), []).append(c)
|
|
260
263
|
# The full-tie spelling to stand: all-lowercase first (commands are
|
|
261
264
|
# lowercase; the capitalised twin is the accident), byte order otherwise.
|
|
262
265
|
tkey = {c: (0, c) if low[c] == c else (1, c) for c in names}
|
|
266
|
+
# Every bucket is walked strongest-first, ties by the likelier spelling:
|
|
267
|
+
# the search can then stop at the first candidate that survives the gates,
|
|
268
|
+
# because no later one can beat it — the same evidence order the map's
|
|
269
|
+
# target rule asks for, laid out along the scan. The walk used to test the
|
|
270
|
+
# letter gates on the whole bucket and keep the best survivor; now the
|
|
271
|
+
# gates run only on the head of the bucket, the few candidates strong
|
|
272
|
+
# enough to be a target at all, which is where the map's time went.
|
|
273
|
+
for bucket_lists in groups.values():
|
|
274
|
+
for ln, members in bucket_lists.items():
|
|
275
|
+
members.sort(key=lambda c: (-ev_of[c][0], -ev_of[c][1],
|
|
276
|
+
-ev_of[c][2], tkey[c]))
|
|
263
277
|
out: dict[str, str] = {}
|
|
264
278
|
for cmd in names:
|
|
265
279
|
st = eng.cmds[cmd]
|
|
@@ -270,12 +284,24 @@ def shadow_map(eng, lines=None) -> dict[str, str]:
|
|
|
270
284
|
continue
|
|
271
285
|
s_set = sets[cmd]
|
|
272
286
|
s_bag = counts[cmd]
|
|
273
|
-
ev =
|
|
274
|
-
|
|
275
|
-
|
|
287
|
+
ev = ev_of[cmd]
|
|
288
|
+
len_cmd = len(cmd)
|
|
289
|
+
best: str | None = None
|
|
290
|
+
best_oev: tuple[int, int, int] | None = None
|
|
291
|
+
for ln in range(len_cmd - 2, len_cmd + 3):
|
|
276
292
|
for other in buckets.get(ln, ()):
|
|
277
293
|
if other == cmd:
|
|
278
294
|
continue
|
|
295
|
+
oev = ev_of[other]
|
|
296
|
+
# The target must be the stronger spelling — or, on a full
|
|
297
|
+
# tie, the likelier spelling (the tie bullet above). Anything
|
|
298
|
+
# weaker is somebody else's shadow, not this line's habit.
|
|
299
|
+
# The bucket is ordered strongest-first, so the first candidate
|
|
300
|
+
# that fails here fails for every candidate after it too: one
|
|
301
|
+
# comparison ends the bucket, where the walk used to gate-test
|
|
302
|
+
# hundreds of lines that could never have been the target.
|
|
303
|
+
if oev < ev or (oev == ev and tkey[other] > tkey[cmd]):
|
|
304
|
+
break
|
|
279
305
|
o_set = sets[other]
|
|
280
306
|
if len(o_set - s_set) > 2 or len(s_set - o_set) > 2:
|
|
281
307
|
continue
|
|
@@ -290,20 +316,20 @@ def shadow_map(eng, lines=None) -> dict[str, str]:
|
|
|
290
316
|
surplus += extra
|
|
291
317
|
if surplus > 2:
|
|
292
318
|
break
|
|
293
|
-
if surplus > 2 or surplus - (
|
|
294
|
-
continue
|
|
295
|
-
ost = eng.cmds[other]
|
|
296
|
-
oev = (ost.success, ost.freq, ost.last_ts)
|
|
297
|
-
# The target must be the stronger spelling — or, on a full
|
|
298
|
-
# tie, the likelier spelling (the tie bullet above). Anything
|
|
299
|
-
# weaker is somebody else's shadow, not this line's habit.
|
|
300
|
-
if oev < ev or (oev == ev and tkey[other] > tkey[cmd]):
|
|
319
|
+
if surplus > 2 or surplus - (len_cmd - len(other)) > 2:
|
|
301
320
|
continue
|
|
302
321
|
if lev1(low[cmd], low[other], 2) > 2:
|
|
303
322
|
continue
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
323
|
+
# Strongest-first order says this survivor is the best target
|
|
324
|
+
# its own bucket holds: an equal-evidence one behind it spells
|
|
325
|
+
# itself less plausibly, and a weaker one is no target at all.
|
|
326
|
+
# The five length buckets are still compared against each
|
|
327
|
+
# other — a stronger target may sit two lengths away — so the
|
|
328
|
+
# survivor is kept and the next bucket is asked.
|
|
329
|
+
if best is None or oev > best_oev or \
|
|
330
|
+
(oev == best_oev and tkey[other] < tkey[best]):
|
|
331
|
+
best, best_oev = other, oev
|
|
332
|
+
break
|
|
307
333
|
if best is not None:
|
|
308
|
-
out[cmd] = best
|
|
334
|
+
out[cmd] = best
|
|
309
335
|
return out
|