offhands 0.1.14 → 0.1.16

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 (2) hide show
  1. package/dist/daemon.mjs +71 -20
  2. package/package.json +4 -1
package/dist/daemon.mjs CHANGED
@@ -4961,7 +4961,8 @@ var AsyncEventQueue = class {
4961
4961
  var APPROVAL_MCP_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "approval-mcp.mjs");
4962
4962
  var CLAUDE_PREALLOWED_OFFHAND_TOOLS = [
4963
4963
  "mcp__offhand__offhand_status_update",
4964
- "mcp__offhand__offhand_context"
4964
+ "mcp__offhand__offhand_context",
4965
+ "mcp__offhand__offhand_send_file"
4965
4966
  ];
4966
4967
  function buildClaudeArgs(run, approvalUrl2) {
4967
4968
  const args2 = ["-p", run.prompt, "--output-format", "stream-json", "--verbose", "--include-partial-messages"];
@@ -6830,6 +6831,12 @@ function extractTerms(prompt, maxTerms = 8) {
6830
6831
  }
6831
6832
  return deduped.slice().sort((a2, b2) => b2.length - a2.length).slice(0, maxTerms);
6832
6833
  }
6834
+ var JUNK_EXTS = [".stackdump", ".log", ".tmp", ".bak", ".orig", ".rej", ".swp", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".mp4", ".mov", ".zip", ".tgz"];
6835
+ var JUNK_BASENAMES = /* @__PURE__ */ new Set([".ds_store", "thumbs.db"]);
6836
+ function isJunk(path) {
6837
+ const base = (path.split(/[\\/]/).pop() ?? "").toLowerCase();
6838
+ return JUNK_BASENAMES.has(base) || base.endsWith("~") || JUNK_EXTS.some((e) => base.endsWith(e));
6839
+ }
6833
6840
  var SECRET_BASENAMES = /* @__PURE__ */ new Set([".npmrc", ".netrc", "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519", "credentials.json", "secrets.json"]);
6834
6841
  var SECRET_EXTS = [".pem", ".key", ".p12", ".pfx", ".keystore", ".jks", ".env"];
6835
6842
  var ENV_TEMPLATE_SUFFIXES = [".example", ".sample", ".template", ".dist"];
@@ -6841,16 +6848,16 @@ function isSecretLike(path) {
6841
6848
  }
6842
6849
  function parsePorcelainZ(out) {
6843
6850
  const parts = out.split("\0");
6844
- const paths = [];
6851
+ const entries = [];
6845
6852
  for (let i2 = 0; i2 < parts.length; i2++) {
6846
6853
  const p2 = parts[i2];
6847
6854
  if (p2.length < 4) continue;
6848
6855
  const x2 = p2[0];
6849
6856
  const y2 = p2[1];
6850
6857
  if (x2 === "R" || x2 === "C" || y2 === "R" || y2 === "C") i2++;
6851
- paths.push(p2.slice(3));
6858
+ entries.push({ path: p2.slice(3), untracked: x2 === "?" && y2 === "?" });
6852
6859
  }
6853
- return paths;
6860
+ return entries;
6854
6861
  }
6855
6862
  function isDirectory(abs) {
6856
6863
  try {
@@ -6875,15 +6882,20 @@ function stem(word) {
6875
6882
  function pathWords(path) {
6876
6883
  const split = (s2) => s2.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean).map(stem);
6877
6884
  const slash = path.lastIndexOf("/");
6878
- const base = new Set(split(path.slice(slash + 1)));
6885
+ const file = path.slice(slash + 1);
6886
+ const base = new Set(split(file));
6879
6887
  const all = /* @__PURE__ */ new Set([...split(path.slice(0, Math.max(slash, 0))), ...base]);
6880
- return { all, base };
6888
+ const nameWords = split(file.replace(/\.[^.]+$/, ""));
6889
+ return { all, base, last: nameWords[nameWords.length - 1] ?? "" };
6881
6890
  }
6882
6891
  var NOISE_FILES = /* @__PURE__ */ new Set(["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb", "bun.lock", "composer.lock", "cargo.lock", "poetry.lock"]);
6883
6892
  var isNoise = (p2) => NOISE_FILES.has((p2.split("/").pop() ?? "").toLowerCase());
6884
6893
  var MAX_MATCHED_SHOWN = 14;
6885
6894
  var MAX_CONTENT_PROBES = 10;
6886
6895
  var MAX_CONTENT_MATCHES = 6;
6896
+ var COVERAGE_BONUS = 0.3;
6897
+ var ROLE_WORD_FACTOR = 0.4;
6898
+ var MIN_SOLID_TO_DROP_WEAK = 5;
6887
6899
  var MAX_UNMATCHED_WITH_MATCHES = 3;
6888
6900
  var MAX_NAME_MATCHES = 10;
6889
6901
  var MAX_DIRTY_ONLY = 8;
@@ -6914,14 +6926,23 @@ async function buildContext(workspace, prompt, opts = {}) {
6914
6926
  const { findings, retrievalOpsRun, ripgrepAvailable, gitState } = result;
6915
6927
  if (findings.length === 0) return null;
6916
6928
  const rank = (f2) => f2.matchScore > 0 ? f2.dirty ? 5 : 4 : f2.dirty ? 2 : 1;
6917
- findings.sort((a2, b2) => rank(b2) - rank(a2) || b2.matchScore - a2.matchScore);
6929
+ const strength = (f2) => f2.matchScore * (1 + COVERAGE_BONUS * Math.max(f2.terms.size - 1, 0));
6930
+ findings.sort((a2, b2) => rank(b2) - rank(a2) || strength(b2) - strength(a2));
6918
6931
  const strongMatches = findings.filter((f2) => f2.matchScore > 0).length;
6919
6932
  const unmatchedCap = strongMatches >= 3 ? MAX_UNMATCHED_WITH_MATCHES : Infinity;
6933
+ const solidCount = findings.filter((f2) => f2.solid > 0).length;
6934
+ const droppingWeak = solidCount >= MIN_SOLID_TO_DROP_WEAK;
6935
+ const droppingUntracked = strongMatches >= 3;
6920
6936
  let unmatched = 0;
6921
6937
  let matchedShown = 0;
6922
- const shown = findings.filter(
6923
- (f2) => f2.matchScore > 0 ? ++matchedShown <= MAX_MATCHED_SHOWN : ++unmatched <= unmatchedCap
6924
- );
6938
+ const shown = findings.filter((f2) => {
6939
+ if (f2.matchScore > 0) {
6940
+ if (droppingWeak && f2.solid === 0) return false;
6941
+ return ++matchedShown <= MAX_MATCHED_SHOWN;
6942
+ }
6943
+ if (droppingUntracked && f2.untracked) return false;
6944
+ return ++unmatched <= unmatchedCap;
6945
+ });
6925
6946
  const { text: block, selectedCount } = formatBlock(prompt, shown, gitState, maxChars);
6926
6947
  if (selectedCount === 0) return null;
6927
6948
  return {
@@ -6944,7 +6965,7 @@ async function gatherFindings(workspace, prompt) {
6944
6965
  const touch = (path) => {
6945
6966
  let f2 = byPath.get(path);
6946
6967
  if (!f2) {
6947
- f2 = { path, reasons: [], dirty: false, matchScore: 0 };
6968
+ f2 = { path, reasons: [], dirty: false, matchScore: 0, terms: /* @__PURE__ */ new Set(), solid: 0, untracked: false };
6948
6969
  byPath.set(path, f2);
6949
6970
  }
6950
6971
  return f2;
@@ -6972,7 +6993,8 @@ async function gatherFindings(workspace, prompt) {
6972
6993
  if (statusOut !== null) {
6973
6994
  const entries = parsePorcelainZ(statusOut);
6974
6995
  let missing = 0;
6975
- for (const p2 of entries) {
6996
+ const untrackedSet = /* @__PURE__ */ new Set();
6997
+ for (const { path: p2, untracked } of entries) {
6976
6998
  const rel = liveRel(p2);
6977
6999
  if (rel === null) {
6978
7000
  missing++;
@@ -6980,14 +7002,22 @@ async function gatherFindings(workspace, prompt) {
6980
7002
  }
6981
7003
  if (isSecretLike(rel)) continue;
6982
7004
  if (rel.endsWith("/") || isDirectory(join6(gitRoot, p2))) dirtyDirs.push(rel.replace(/\/?$/, "/"));
6983
- else dirtySet.add(rel);
7005
+ else {
7006
+ dirtySet.add(rel);
7007
+ if (untracked) untrackedSet.add(rel);
7008
+ }
6984
7009
  }
7010
+ const dirtyOrder = [
7011
+ ...[...dirtySet].filter((r2) => !untrackedSet.has(r2)),
7012
+ ...[...dirtySet].filter((r2) => untrackedSet.has(r2) && !isJunk(r2))
7013
+ ];
6985
7014
  let dirtyOnly = 0;
6986
- for (const rel of dirtySet) {
7015
+ for (const rel of dirtyOrder) {
6987
7016
  if (dirtyOnly >= MAX_DIRTY_ONLY) break;
6988
7017
  if (isNoise(rel)) continue;
6989
7018
  const f2 = touch(rel);
6990
7019
  f2.dirty = true;
7020
+ f2.untracked = untrackedSet.has(rel);
6991
7021
  note(f2, "currently dirty");
6992
7022
  dirtyOnly++;
6993
7023
  }
@@ -7010,6 +7040,14 @@ async function gatherFindings(workspace, prompt) {
7010
7040
  else if (!stems.has(stem(t2))) stems.set(stem(t2), t2);
7011
7041
  }
7012
7042
  const words = all.map(pathWords);
7043
+ const lastByDir = /* @__PURE__ */ new Map();
7044
+ words.forEach((w2, i2) => {
7045
+ if (!w2.last) return;
7046
+ const key = `${all[i2].slice(0, all[i2].lastIndexOf("/") + 1)}\0${w2.last}`;
7047
+ lastByDir.set(key, (lastByDir.get(key) ?? 0) + 1);
7048
+ });
7049
+ const roleWords = /* @__PURE__ */ new Set();
7050
+ for (const [key, n2] of lastByDir) if (n2 >= 2) roleWords.add(key.slice(key.indexOf("\0") + 1));
7013
7051
  const lowered = literals.length > 0 ? all.map((p2) => p2.toLowerCase()) : [];
7014
7052
  const idf = /* @__PURE__ */ new Map();
7015
7053
  const wording = /* @__PURE__ */ new Map();
@@ -7028,15 +7066,21 @@ async function gatherFindings(workspace, prompt) {
7028
7066
  const scored = [];
7029
7067
  for (let i2 = 0; i2 < all.length; i2++) {
7030
7068
  let score = 0;
7069
+ let solid = 0;
7031
7070
  const matched = [];
7032
7071
  for (const [key, weight] of idf) {
7033
7072
  const isLiteral = literals.includes(key);
7034
- if (isLiteral ? lowered[i2].includes(key) : words[i2].base.has(key)) score += weight * 2;
7035
- else if (!isLiteral && words[i2].all.has(key)) score += weight;
7036
- else continue;
7073
+ if (isLiteral ? lowered[i2].includes(key) : words[i2].base.has(key)) {
7074
+ const roleOnly = !isLiteral && roleWords.has(key) && words[i2].last === key;
7075
+ score += weight * 2 * (roleOnly ? ROLE_WORD_FACTOR : 1);
7076
+ if (!roleOnly) solid += weight * 2;
7077
+ } else if (!isLiteral && words[i2].all.has(key)) {
7078
+ score += weight;
7079
+ solid += weight;
7080
+ } else continue;
7037
7081
  matched.push(wording.get(key));
7038
7082
  }
7039
- if (score > 0) scored.push({ path: all[i2], score, matched });
7083
+ if (score > 0) scored.push({ path: all[i2], score, solid, matched });
7040
7084
  }
7041
7085
  scored.sort((a2, b2) => b2.score - a2.score);
7042
7086
  let taken = 0;
@@ -7045,6 +7089,8 @@ async function gatherFindings(workspace, prompt) {
7045
7089
  if (isSecretLike(s2.path) || !existsSync7(join6(workspace, s2.path))) continue;
7046
7090
  const f2 = touch(s2.path);
7047
7091
  f2.matchScore += s2.score;
7092
+ f2.solid += s2.solid;
7093
+ for (const m3 of s2.matched) f2.terms.add(stem(m3));
7048
7094
  if (isDirty2(s2.path)) f2.dirty = true;
7049
7095
  note(f2, `path matches ${s2.matched.map((m3) => `"${m3}"`).join(", ")}`);
7050
7096
  taken++;
@@ -7084,6 +7130,8 @@ async function gatherFindings(workspace, prompt) {
7084
7130
  if (isSecretLike(file) || !existsSync7(join6(workspace, file))) continue;
7085
7131
  const f2 = touch(file);
7086
7132
  f2.matchScore += score;
7133
+ f2.solid += score;
7134
+ for (const w2 of words) f2.terms.add(stem(w2));
7087
7135
  if (isDirty2(file)) f2.dirty = true;
7088
7136
  note(f2, `content mentions ${words.slice(0, 3).map((w2) => `"${w2}"`).join(", ")}`);
7089
7137
  taken++;
@@ -7098,6 +7146,8 @@ async function gatherFindings(workspace, prompt) {
7098
7146
  const term = terms.find((t2) => hit.text.toLowerCase().includes(t2));
7099
7147
  const f2 = touch(file);
7100
7148
  f2.matchScore += 1;
7149
+ f2.solid += 1;
7150
+ if (term) f2.terms.add(stem(term));
7101
7151
  if (isDirty2(file)) f2.dirty = true;
7102
7152
  note(f2, term ? `matched "${term}"` : "matched search terms");
7103
7153
  }
@@ -7109,7 +7159,7 @@ async function gatherFindings(workspace, prompt) {
7109
7159
  let taken = 0;
7110
7160
  for (const p2 of recent) {
7111
7161
  if (taken >= MAX_RECENT) break;
7112
- if (isSecretLike(p2) || isNoise(p2) || !existsSync7(join6(workspace, p2))) continue;
7162
+ if (isSecretLike(p2) || isNoise(p2) || isJunk(p2) || !existsSync7(join6(workspace, p2))) continue;
7113
7163
  note(touch(p2), "recently changed");
7114
7164
  taken++;
7115
7165
  }
@@ -43792,7 +43842,8 @@ var ApprovalBroker = class {
43792
43842
  }
43793
43843
  };
43794
43844
  function classifyRisk(toolName, input) {
43795
- if (toolName === "offhand_send_file") return "high";
43845
+ const bareName = toolName.replace(/^mcp__.+?__/, "");
43846
+ if (bareName === "offhand_send_file") return "high";
43796
43847
  const i2 = input ?? {};
43797
43848
  const text = [toolName, i2.command, i2.file_path, i2.path, i2.filepath].filter((x2) => typeof x2 === "string").join(" ");
43798
43849
  return /\b(rm|del|rmdir|rd|format|mkfs|shutdown|reboot|kill|drop\s+table|truncate|git\s+push\s+--force|--hard)\b/i.test(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offhands",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Phone → coding-agent relay. Daemon runs on your laptop, PWA on your phone. E2E encrypted.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -22,5 +22,8 @@
22
22
  },
23
23
  "devDependencies": {
24
24
  "esbuild": "^0.24.0"
25
+ },
26
+ "dependencies": {
27
+ "offhands": "^0.1.14"
25
28
  }
26
29
  }